diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..f571fb2a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +coverage/* +dist/* +node_modules/* +playground/* +benchmark/* diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index fcb23152..00000000 --- a/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "rules": { - "curly": [2, "multi-line"], - "eol-last": [2], - "indent": [2, 4], - "quotes": [2, "single"], - "linebreak-style": [2, "unix"], - "max-len": [2, 80, 4], - "semi": [2, "always"], - "strict": [2, "never"], - "no-console": [2, {"allow": ["log", "warn", "error", "groupCollapsed", "groupEnd", "time", "timeEnd"]}], - "valid-jsdoc": ["error", {"requireReturn": false}] - }, - "env": { - "node": true, - "browser": true, - "worker": true - }, - "extends": "eslint:recommended" -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..3010f60d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['scratch', 'scratch/node', 'scratch/es6'] +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b7671ba4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,37 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Explicitly specify line endings for as many files as possible. +# People who (for example) rsync between Windows and Linux need this. + +# File types which we know are binary +*.sb2 binary + +# Prefer LF for most file types +*.css text eol=lf +*.frag text eol=lf +*.htm text eol=lf +*.html text eol=lf +*.iml text eol=lf +*.js text eol=lf +*.js.map text eol=lf +*.json text eol=lf +*.md text eol=lf +*.vert text eol=lf +*.xml text eol=lf +*.yml text eol=lf + +# Prefer LF for these files +.editorconfig text eol=lf +.eslintignore text eol=lf +.eslintrc text eol=lf +.gitattributes text eol=lf +.gitignore text eol=lf +.gitmodules text eol=lf +.npmignore text eol=lf +LICENSE text eol=lf +Makefile text eol=lf +README text eol=lf +TRADEMARK text eol=lf + +# Use CRLF for Windows-specific file types diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..9835b7ba --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,13 @@ +## Contributing +The development of Scratch is an ongoing process, and we love to have people in the Scratch and open source communities help us along the way. + +If you're interested in contributing, please take a look at the [issues](https://github.com/LLK/scratch-vm/issues) on this repository. +Two great ways of helping are by identifying bugs and documenting them as issues, or fixing issues and creating pull requests. When looking for bugs to fix, please look for the ["Help Wanted" label](https://github.com/LLK/scratch-vm/issues?q=label%3A%22help+wanted%22). Bugs with this label have been specifically set aside for Open Source contributors. Issues without the label can also be worked on but we ask that you comment on the issue prior to starting work. When submitting pull requests please be patient -- it can take a while to find time to review them. The organization and class structures can't be radically changed without significant coordination and collaboration from the Scratch Team, so these types of changes should be avoided. + +It's been said that the Scratch Team spends about one hour of design discussion for every pixel in Scratch, but some think that estimate is a little low. While we welcome suggestions for new features in our [suggestions forum](https://scratch.mit.edu/discuss/1/) (especially ones that come with mockups), we are unlikely to accept PRs with new features that haven't been thought through and discussed as a group. Why? Because we have a strong belief in the value of keeping things simple for new users. To learn more about our design philosophy, see [the Scratch Developers page](https://scratch.mit.edu/developers), or [this paper](http://web.media.mit.edu/~mres/papers/Scratch-CACM-final.pdf). + +Beyond this repo, there are also some other resources that you might want to take a look at: +* [Community Guidelines](https://github.com/LLK/scratch-www/wiki/Community-Guidelines) (we find it important to maintain a constructive and welcoming community, just like on Scratch) +* [Open Source forum](https://scratch.mit.edu/discuss/49/) on Scratch +* [Suggestions forum](https://scratch.mit.edu/discuss/1/) on Scratch +* [Bugs & Glitches forum](https://scratch.mit.edu/discuss/3/) on Scratch diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 9ea236ba..38756937 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,15 +1,15 @@ -### Expected behavior +### Expected Behavior _Please describe what should happen_ -### Actual behavior +### Actual Behavior _Describe what actually happens_ -### Steps to reproduce +### Steps to Reproduce _Explain what someone needs to do in order to see what's described in *Actual behavior* above_ -### Operating system and browser +### Operating System and Browser _e.g. Mac OS 10.11.6 Safari 10.0_ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 696bd38d..33ff6dfd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,15 @@ -### Proposed changes +### Resolves + +_What Github issue does this resolve (please include link)?_ + +### Proposed Changes _Describe what this Pull Request does_ -### Reason for changes +### Reason for Changes -_Explain why these changes should be made. Please include an issue # if applicable._ +_Explain why these changes should be made_ + +### Test Coverage + +_Please show how you have added tests to cover your changes_ diff --git a/.gitignore b/.gitignore index aad54ff5..124efe5d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,21 @@ .DS_Store # NPM +package-lock.json /node_modules npm-* # Testing /.nyc_output /coverage -/dist.js -/vm.js -/vm.min.js -/playground/assets -/playground/media -/playground/vendor.js -/playground/vm.js -/playground/zenburn.css + +# IDEA +/.idea + +# Build +/dist +/playground +/benchmark + +# Localization +/translations diff --git a/.travis.yml b/.travis.yml index a44d5380..4f98c136 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,35 +1,41 @@ language: node_js node_js: -- '4' -- stable +- 6 +- node +env: + - NPM_SCRIPT="tap:unit -- --jobs=4" + - NPM_SCRIPT="tap:integration -- --jobs=4" + - NODE_ENV=production sudo: false cache: directories: - node_modules install: -- npm install -- npm update -after_script: -- | - # RELEASE_BRANCHES and NPM_TOKEN defined in Travis settings panel - declare exitCode - $(npm bin)/travis-after-all - exitCode=$? - if [[ - # Execute after all jobs finish successfully - $exitCode = 0 && - # Only release on release branches - $RELEASE_BRANCHES =~ $TRAVIS_BRANCH && - # Don't release on PR builds - $TRAVIS_PULL_REQUEST = "false" - ]]; then - # Authenticate NPM - echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc - # Set version to timestamp - npm --no-git-tag-version version $($(npm bin)/json -f package.json version)-prerelease.$(date +%s) - npm publish - # Publish to gh-pages as most recent committer - git config --global user.email $(git log --pretty=format:"%ce" -n1) - git config --global user.name $(git log --pretty=format:"%cn" -n1) - ./node_modules/.bin/gh-pages -x -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git -d playground -m "Build for $(git log --pretty=format:%H)" - fi +- npm --production=false install +- npm --production=false update +script: npm run $NPM_SCRIPT +jobs: + include: + - env: NPM_SCRIPT=lint + node_js: 6 + - stage: release + node_js: 6 + env: NPM_SCRIPT=build + before_deploy: + - npm --no-git-tag-version version $($(npm bin)/json -f package.json version)-prerelease.$(date +%s) + - git config --global user.email $(git log --pretty=format:"%ae" -n1) + - git config --global user.name $(git log --pretty=format:"%an" -n1) + deploy: + - provider: script + "on": + all_branches: true + condition: $RELEASE_BRANCHES =~ $TRAVIS_BRANCH + skip_cleanup: true + script: npm run --silent deploy -- -x -r $GH_PAGES_REPO + - provider: npm + "on": + all_branches: true + condition: $RELEASE_BRANCHES =~ $TRAVIS_BRANCH + skip_cleanup: true + email: $NPM_EMAIL + api_key: $NPM_TOKEN diff --git a/.tx/config b/.tx/config new file mode 100644 index 00000000..b6cb702a --- /dev/null +++ b/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.com + +[experimental-scratch.pen] +file_filter = translations/pen/.json +source_file = translations/pen/en.json +source_lang = en +type = CHROME diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index ddd2e36c..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,25 +0,0 @@ -## Contributing -The development of scratch-vm is an ongoing process, -and we love to have people in the Scratch and open source communities help us along the way. - -If you're interested in contributing, please take a look at the -[issues](https://github.com/LLK/scratch-vm/issues) on this repository. -Two great ways of helping are by identifying bugs and documenting them as issues, -or fixing issues and creating pull requests. When submitting pull requests please be patient --- it can take a while to find time to review them. -The organization and class structures can't be radically changed without significant coordination -and collaboration from the Scratch Team, so these types of changes should be avoided. - -It's been said that the Scratch Team spends about one hour of design discussion for every pixel in Scratch, -but some think that estimate is a little low. While we welcome suggestions for new features in our -[suggestions forum](https://scratch.mit.edu/discuss/1/) (especially ones that come with mockups), we are unlikely to accept PRs with -new features that haven't been thought through and discussed as a group. Why? Because we have a strong belief -in the value of keeping things simple for new users. To learn more about our design philosophy, -see [the Scratch Developers page](https://scratch.mit.edu/developers), or -[this paper](http://web.media.mit.edu/~mres/papers/Scratch-CACM-final.pdf). - -Beyond this repo, there are also some other resources that you might want to take a look at: -* [Community Guidelines](https://github.com/LLK/scratch-www/wiki/Community-Guidelines) (we find it important to maintain a constructive and welcoming community, just like on Scratch) -* [Open Source forum](https://scratch.mit.edu/discuss/49/) on Scratch -* [Suggestions forum](https://scratch.mit.edu/discuss/1/) on Scratch -* [Bugs & Glitches forum](https://scratch.mit.edu/discuss/3/) on Scratch diff --git a/Makefile b/Makefile deleted file mode 100644 index 14a5870b..00000000 --- a/Makefile +++ /dev/null @@ -1,34 +0,0 @@ -ESLINT=./node_modules/.bin/eslint -NODE=node -TAP=./node_modules/.bin/tap -WEBPACK=./node_modules/.bin/webpack --progress --colors -WEBPACK_DEV_SERVER=./node_modules/.bin/webpack-dev-server - -# ------------------------------------------------------------------------------ - -build: - $(WEBPACK) - -watch: - $(WEBPACK) --watch - -serve: - $(WEBPACK_DEV_SERVER) - -# ------------------------------------------------------------------------------ - -lint: - $(ESLINT) ./src/*.js - $(ESLINT) ./src/**/*.js - $(ESLINT) ./test/**/*.js - -test: - @make lint - $(TAP) ./test/{unit,integration}/*.js - -coverage: - $(TAP) ./test/{unit,integration}/*.js --coverage --coverage-report=lcov - -# ------------------------------------------------------------------------------ - -.PHONY: build lint test coverage benchmark serve diff --git a/README.md b/README.md index b701ca10..6bd31f90 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,16 @@ [![Build Status](https://travis-ci.org/LLK/scratch-vm.svg?branch=develop)](https://travis-ci.org/LLK/scratch-vm) [![Coverage Status](https://coveralls.io/repos/github/LLK/scratch-vm/badge.svg?branch=develop)](https://coveralls.io/github/LLK/scratch-vm?branch=develop) -[![Dependency Status](https://david-dm.org/LLK/scratch-vm.svg)](https://david-dm.org/LLK/scratch-vm) -[![devDependency Status](https://david-dm.org/LLK/scratch-vm/dev-status.svg)](https://david-dm.org/LLK/scratch-vm#info=devDependencies) +[![Greenkeeper badge](https://badges.greenkeeper.io/LLK/scratch-vm.svg)](https://greenkeeper.io/) ## Installation This requires you to have Git and Node.js installed. -In your own node environment/application: +To install as a dependency for your own application: ```bash -npm install https://github.com/LLK/scratch-vm.git +npm install scratch-vm ``` -If you want to edit/play yourself: +To set up a development environment to edit scratch-vm yourself: ```bash git clone https://github.com/LLK/scratch-vm.git cd scratch-vm @@ -23,31 +22,27 @@ npm install ## Development Server This requires Node.js to be installed. -For convenience, we've included a development server with the VM. This is sometimes useful when running in an environment that's loading remote resources (e.g., SVGs from the Scratch server). +For convenience, we've included a development server with the VM. This is sometimes useful when running in an environment that's loading remote resources (e.g., SVGs from the Scratch server). If you would like to use your modified VM with the full Scratch 3.0 GUI, [follow the instructions to link the VM to the GUI](https://github.com/LLK/scratch-gui/wiki/Getting-Started). ## Running the Development Server Open a Command Prompt or Terminal in the repository and run: ```bash npm start ``` -Or on Windows: -```bash -StartServerWindows.bat -``` ## Playground -To run the Playground, make sure the dev server's running and go to [http://localhost:8080/](http://localhost:8080/) - you will be directed to the playground, which demonstrates various tools and internal state. +To view the Playground, make sure the dev server's running and go to [http://localhost:8073/playground/](http://localhost:8073/playground/) - you will be directed to the playground, which demonstrates various tools and internal state. ![VM Playground Screenshot](https://i.imgur.com/nOCNqEc.gif) ## Standalone Build ```bash -make build +npm run build ``` ```html - + - - - - - - - diff --git a/playground/playground.css b/playground/playground.css deleted file mode 100644 index 3eccd262..00000000 --- a/playground/playground.css +++ /dev/null @@ -1,73 +0,0 @@ -body { - background: rgb(36,36,36); -} -a { - color: rgb(217,217,217); -} -h2 { - font-size: 1em; -} -#blocks { - position: absolute; - left: 40%; - right: 0; - top: 0; - bottom: 0; - font-family: "Helvetica Neue", Helvetica, sans-serif; -} -#vm-devtools { - color: rgb(217,217,217); - position: absolute; - left: 1%; - right: 60%; - top: 1%; - bottom: 0; - width: 35%; -} -#blockexplorer, #threadexplorer, #importexport { - position: absolute; - height: 75%; - overflow: scroll; - border: 1px solid #fff; - background: rgb(36,36,36); - color: rgb(217,217,217); - font-family: monospace; - font-size: 10pt; - width: 480px; - height: 360px; -} -#tab-blockexplorer, #tab-threadexplorer, #tab-importexport { - display: none; -} -#importExport { - width: 480px; - height: 360px; - background: rgb(36,36,36); - color: rgb(217,217,217); -} -#projectId { - background: rgb(36,36,36); - color: rgb(217,217,217); - font-family: monospace; - font-size: 10pt; -} -ul#playgroundLinks { - display: block; - list-style-type: none; - margin: 0; - padding: 0; - overflow: hidden; - background-color: #333; -} -#playgroundLinks li { - float: left; -} -#playgroundLinks li a { - display: block; - color: white; - text-align: center; - padding: 5px 10px; -} -#playgroundLinks li a:hover { - background-color: #111; -} diff --git a/playground/playground.js b/playground/playground.js deleted file mode 100644 index 577877ae..00000000 --- a/playground/playground.js +++ /dev/null @@ -1,312 +0,0 @@ -var NEW_PROJECT_HASH = 'createEmptyProject'; - -var loadProject = function () { - var id = location.hash.substring(1); - if (id === NEW_PROJECT_HASH) { - window.vm.createEmptyProject(); - return; - } - if (id.length < 1 || !isFinite(id)) { - id = '119615668'; - } - var url = 'https://projects.scratch.mit.edu/internalapi/project/' + - id + '/get/'; - var r = new XMLHttpRequest(); - r.onreadystatechange = function() { - if (this.readyState === 4) { - if (r.status === 200) { - window.vm.loadProject(this.responseText); - } else { - window.vm.createEmptyProject(); - } - } - }; - r.open('GET', url); - r.send(); -}; - -window.onload = function() { - // Lots of global variables to make debugging easier - // Instantiate the VM. - var vm = new window.VirtualMachine(); - window.vm = vm; - - // Loading projects from the server. - document.getElementById('projectLoadButton').onclick = function () { - document.location = '#' + document.getElementById('projectId').value; - location.reload(); - }; - document.getElementById('createEmptyProject').addEventListener('click', - function() { - document.location = '#' + NEW_PROJECT_HASH; - location.reload(); - }); - loadProject(); - - // Instantiate the renderer and connect it to the VM. - var canvas = document.getElementById('scratch-stage'); - var renderer = new window.RenderWebGL(canvas); - window.renderer = renderer; - vm.attachRenderer(renderer); - - // Instantiate scratch-blocks and attach it to the DOM. - var workspace = window.Blockly.inject('blocks', { - media: './media/', - zoom: { - controls: true, - wheel: true, - startScale: 0.75 - }, - colours: { - workspace: '#334771', - flyout: '#283856', - scrollbar: '#24324D', - scrollbarHover: '#0C111A', - insertionMarker: '#FFFFFF', - insertionMarkerOpacity: 0.3, - fieldShadow: 'rgba(255, 255, 255, 0.3)', - dragShadowOpacity: 0.6 - } - }); - window.workspace = workspace; - - // Attach scratch-blocks events to VM. - workspace.addChangeListener(vm.blockListener); - var flyoutWorkspace = workspace.getFlyout().getWorkspace(); - flyoutWorkspace.addChangeListener(vm.flyoutBlockListener); - - // Create FPS counter. - var stats = new window.Stats(); - document.getElementById('tab-renderexplorer').appendChild(stats.dom); - stats.dom.style.position = 'relative'; - stats.begin(); - - // Playground data tabs. - // Block representation tab. - var blockexplorer = document.getElementById('blockexplorer'); - var updateBlockExplorer = function(blocks) { - blockexplorer.innerHTML = JSON.stringify(blocks, null, 2); - window.hljs.highlightBlock(blockexplorer); - }; - - // Thread representation tab. - var threadexplorer = document.getElementById('threadexplorer'); - var cachedThreadJSON = ''; - var updateThreadExplorer = function (newJSON) { - if (newJSON != cachedThreadJSON) { - cachedThreadJSON = newJSON; - threadexplorer.innerHTML = cachedThreadJSON; - window.hljs.highlightBlock(threadexplorer); - } - }; - - // Only request data from the VM thread if the appropriate tab is open. - window.exploreTabOpen = false; - var getPlaygroundData = function () { - vm.getPlaygroundData(); - if (window.exploreTabOpen) { - window.requestAnimationFrame(getPlaygroundData); - } - }; - - // VM handlers. - // Receipt of new playground data (thread, block representations). - vm.on('playgroundData', function(data) { - updateThreadExplorer(data.threads); - updateBlockExplorer(data.blocks); - }); - - // Receipt of new block XML for the selected target. - vm.on('workspaceUpdate', function (data) { - workspace.clear(); - var dom = window.Blockly.Xml.textToDom(data.xml); - window.Blockly.Xml.domToWorkspace(dom, workspace); - }); - - // Receipt of new list of targets, selected target update. - var selectedTarget = document.getElementById('selectedTarget'); - vm.on('targetsUpdate', function (data) { - // Clear select box. - while (selectedTarget.firstChild) { - selectedTarget.removeChild(selectedTarget.firstChild); - } - // Generate new select box. - for (var i = 0; i < data.targetList.length; i++) { - var targetOption = document.createElement('option'); - targetOption.setAttribute('value', data.targetList[i][0]); - // If target id matches editingTarget id, select it. - if (data.targetList[i][0] == data.editingTarget) { - targetOption.setAttribute('selected', 'selected'); - } - targetOption.appendChild( - document.createTextNode(data.targetList[i][1]) - ); - selectedTarget.appendChild(targetOption); - } - }); - selectedTarget.onchange = function () { - vm.setEditingTarget(this.value); - }; - - // Feedback for stacks and blocks running. - vm.on('STACK_GLOW_ON', function(data) { - workspace.glowStack(data.id, true); - }); - vm.on('STACK_GLOW_OFF', function(data) { - workspace.glowStack(data.id, false); - }); - vm.on('BLOCK_GLOW_ON', function(data) { - workspace.glowBlock(data.id, true); - }); - vm.on('BLOCK_GLOW_OFF', function(data) { - workspace.glowBlock(data.id, false); - }); - vm.on('VISUAL_REPORT', function(data) { - workspace.reportValue(data.id, data.value); - }); - - // Feed mouse events as VM I/O events. - document.addEventListener('mousemove', function (e) { - var rect = canvas.getBoundingClientRect(); - var coordinates = { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - canvasWidth: rect.width, - canvasHeight: rect.height - }; - window.vm.postIOData('mouse', coordinates); - }); - canvas.addEventListener('mousedown', function (e) { - var rect = canvas.getBoundingClientRect(); - var data = { - isDown: true, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - canvasWidth: rect.width, - canvasHeight: rect.height - }; - window.vm.postIOData('mouse', data); - e.preventDefault(); - }); - canvas.addEventListener('mouseup', function (e) { - var rect = canvas.getBoundingClientRect(); - var data = { - isDown: false, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - canvasWidth: rect.width, - canvasHeight: rect.height - }; - window.vm.postIOData('mouse', data); - e.preventDefault(); - }); - - // Feed keyboard events as VM I/O events. - document.addEventListener('keydown', function (e) { - // Don't capture keys intended for Blockly inputs. - if (e.target != document && e.target != document.body) { - return; - } - window.vm.postIOData('keyboard', { - keyCode: e.keyCode, - isDown: true - }); - e.preventDefault(); - }); - document.addEventListener('keyup', function(e) { - // Always capture up events, - // even those that have switched to other targets. - window.vm.postIOData('keyboard', { - keyCode: e.keyCode, - isDown: false - }); - // E.g., prevent scroll. - if (e.target != document && e.target != document.body) { - e.preventDefault(); - } - }); - - // Run threads - vm.start(); - - // Inform VM of animation frames. - var animate = function() { - stats.end(); - stats.begin(); - window.vm.animationFrame(); - requestAnimationFrame(animate); - }; - requestAnimationFrame(animate); - - // Handlers for green flag and stop all. - document.getElementById('greenflag').addEventListener('click', function() { - vm.greenFlag(); - }); - document.getElementById('stopall').addEventListener('click', function() { - vm.stopAll(); - }); - document.getElementById('turbomode').addEventListener('change', function() { - var turboOn = document.getElementById('turbomode').checked; - vm.setTurboMode(turboOn); - }); - document.getElementById('pausemode').addEventListener('change', function() { - var pauseOn = document.getElementById('pausemode').checked; - vm.setPauseMode(pauseOn); - }); - document.getElementById('compatmode').addEventListener('change', - function() { - var compatibilityMode = document.getElementById('compatmode').checked; - vm.setCompatibilityMode(compatibilityMode); - }); - document.getElementById('singlestepmode').addEventListener('change', - function() { - var singleStep = document.getElementById('singlestepmode').checked; - vm.setSingleSteppingMode(singleStep); - }); - document.getElementById('singlestepspeed').addEventListener('input', - function() { - var speed = document.getElementById('singlestepspeed').value; - vm.setSingleSteppingSpeed(speed); - }); - - var tabBlockExplorer = document.getElementById('tab-blockexplorer'); - var tabThreadExplorer = document.getElementById('tab-threadexplorer'); - var tabRenderExplorer = document.getElementById('tab-renderexplorer'); - var tabImportExport = document.getElementById('tab-importexport'); - - // Handlers to show different explorers. - document.getElementById('threadexplorer-link').addEventListener('click', - function () { - window.exploreTabOpen = true; - getPlaygroundData(); - tabBlockExplorer.style.display = 'none'; - tabRenderExplorer.style.display = 'none'; - tabThreadExplorer.style.display = 'block'; - tabImportExport.style.display = 'none'; - }); - document.getElementById('blockexplorer-link').addEventListener('click', - function () { - window.exploreTabOpen = true; - getPlaygroundData(); - tabBlockExplorer.style.display = 'block'; - tabRenderExplorer.style.display = 'none'; - tabThreadExplorer.style.display = 'none'; - tabImportExport.style.display = 'none'; - }); - document.getElementById('renderexplorer-link').addEventListener('click', - function () { - window.exploreTabOpen = false; - tabBlockExplorer.style.display = 'none'; - tabRenderExplorer.style.display = 'block'; - tabThreadExplorer.style.display = 'none'; - tabImportExport.style.display = 'none'; - }); - document.getElementById('importexport-link').addEventListener('click', - function () { - window.exploreTabOpen = false; - tabBlockExplorer.style.display = 'none'; - tabRenderExplorer.style.display = 'none'; - tabThreadExplorer.style.display = 'none'; - tabImportExport.style.display = 'block'; - }); -}; diff --git a/src/.eslintrc.js b/src/.eslintrc.js new file mode 100644 index 00000000..a704f703 --- /dev/null +++ b/src/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['scratch', 'scratch/es6'], + env: { + browser: true + } +}; diff --git a/src/blocks/scratch3_control.js b/src/blocks/scratch3_control.js index 571b1478..975dfeb8 100644 --- a/src/blocks/scratch3_control.js +++ b/src/blocks/scratch3_control.js @@ -1,139 +1,135 @@ -var Cast = require('../util/cast'); -var Timer = require('../util/timer'); +const Cast = require('../util/cast'); + +class Scratch3ControlBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } -function Scratch3ControlBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; -} + getPrimitives () { + return { + control_repeat: this.repeat, + control_repeat_until: this.repeatUntil, + 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 + }; + } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3ControlBlocks.prototype.getPrimitives = function() { - return { - 'control_repeat': this.repeat, - 'control_repeat_until': this.repeatUntil, - '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 - }; -}; + getHats () { + return { + control_start_as_clone: { + restartExistingThreads: false + } + }; + } -Scratch3ControlBlocks.prototype.getHats = function () { - return { - 'control_start_as_clone': { - restartExistingThreads: false + repeat (args, util) { + const times = Math.floor(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); } - }; -}; - -Scratch3ControlBlocks.prototype.repeat = function(args, util) { - var times = Math.floor(Cast.toNumber(args.TIMES)); - // Initialize loop - if (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 true, start the branch. + if (!condition) { + util.startBranch(1, true); + } } -}; -Scratch3ControlBlocks.prototype.repeatUntil = function(args, util) { - var condition = Cast.toBoolean(args.CONDITION); - // If the condition is true, start the branch. - if (!condition) { - util.startBranch(1, true); - } -}; - -Scratch3ControlBlocks.prototype.waitUntil = function(args, util) { - var condition = Cast.toBoolean(args.CONDITION); - if (!condition) { - util.yield(); - } -}; - -Scratch3ControlBlocks.prototype.forever = function(args, util) { - util.startBranch(1, true); -}; - -Scratch3ControlBlocks.prototype.wait = function(args, util) { - if (!util.stackFrame.timer) { - util.stackFrame.timer = new Timer(); - util.stackFrame.timer.start(); - util.yield(); - this.runtime.requestRedraw(); - } else { - var duration = Math.max(0, 1000 * Cast.toNumber(args.DURATION)); - if (util.stackFrame.timer.timeElapsed() < duration) { + waitUntil (args, util) { + const condition = Cast.toBoolean(args.CONDITION); + if (!condition) { util.yield(); } } -}; -Scratch3ControlBlocks.prototype.if = function(args, util) { - var condition = Cast.toBoolean(args.CONDITION); - if (condition) { - util.startBranch(1, false); + forever (args, util) { + util.startBranch(1, true); } -}; -Scratch3ControlBlocks.prototype.ifElse = function(args, util) { - var condition = Cast.toBoolean(args.CONDITION); - if (condition) { - util.startBranch(1, false); - } else { - util.startBranch(2, false); + wait (args) { + const duration = Math.max(0, 1000 * Cast.toNumber(args.DURATION)); + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, duration); + }); } -}; -Scratch3ControlBlocks.prototype.stop = function(args, util) { - var 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.stopThread(); + if (args, util) { + const condition = Cast.toBoolean(args.CONDITION); + if (condition) { + util.startBranch(1, false); + } } -}; -Scratch3ControlBlocks.prototype.createClone = function (args, util) { - var cloneTarget; - if (args.CLONE_OPTION == '_myself_') { - cloneTarget = util.target; - } else { - cloneTarget = this.runtime.getSpriteTargetByName(args.CLONE_OPTION); + ifElse (args, util) { + const condition = Cast.toBoolean(args.CONDITION); + if (condition) { + util.startBranch(1, false); + } else { + util.startBranch(2, false); + } } - if (!cloneTarget) { - return; - } - var newClone = cloneTarget.makeClone(); - if (newClone) { - this.runtime.targets.push(newClone); - } -}; -Scratch3ControlBlocks.prototype.deleteClone = function (args, util) { - if (util.target.isOriginal) return; - this.runtime.disposeTarget(util.target); - this.runtime.stopForTarget(util.target); -}; + 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) { + let cloneTarget; + if (args.CLONE_OPTION === '_myself_') { + cloneTarget = util.target; + } else { + cloneTarget = this.runtime.getSpriteTargetByName(args.CLONE_OPTION); + } + if (!cloneTarget) { + return; + } + const newClone = cloneTarget.makeClone(); + if (newClone) { + this.runtime.targets.push(newClone); + } + } + + deleteClone (args, util) { + if (util.target.isOriginal) return; + this.runtime.disposeTarget(util.target); + this.runtime.stopForTarget(util.target); + } +} module.exports = Scratch3ControlBlocks; diff --git a/src/blocks/scratch3_data.js b/src/blocks/scratch3_data.js index a4017242..0a382e28 100644 --- a/src/blocks/scratch3_data.js +++ b/src/blocks/scratch3_data.js @@ -1,136 +1,149 @@ -var Cast = require('../util/cast'); +const Cast = require('../util/cast'); + +class Scratch3DataBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } -function Scratch3DataBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; -} + getPrimitives () { + return { + data_variable: this.getVariable, + data_setvariableto: this.setVariableTo, + data_changevariableby: this.changeVariableBy, + data_listcontents: this.getListContents, + data_addtolist: this.addToList, + data_deleteoflist: this.deleteOfList, + data_insertatlist: this.insertAtList, + data_replaceitemoflist: this.replaceItemOfList, + data_itemoflist: this.getItemOfList, + data_lengthoflist: this.lengthOfList, + data_listcontainsitem: this.listContainsItem + }; + } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3DataBlocks.prototype.getPrimitives = function () { - return { - 'data_variable': this.getVariable, - 'data_setvariableto': this.setVariableTo, - 'data_changevariableby': this.changeVariableBy, - 'data_listcontents': this.getListContents, - 'data_addtolist': this.addToList, - 'data_deleteoflist': this.deleteOfList, - 'data_insertatlist': this.insertAtList, - 'data_replaceitemoflist': this.replaceItemOfList, - 'data_itemoflist': this.getItemOfList, - 'data_lengthoflist': this.lengthOfList, - 'data_listcontainsitem': this.listContainsItem - }; -}; + getVariable (args, util) { + const variable = util.target.lookupOrCreateVariable( + args.VARIABLE.id, args.VARIABLE.name); + return variable.value; + } -Scratch3DataBlocks.prototype.getVariable = function (args, util) { - var variable = util.target.lookupOrCreateVariable(args.VARIABLE); - return variable.value; -}; + setVariableTo (args, util) { + const variable = util.target.lookupOrCreateVariable( + args.VARIABLE.id, args.VARIABLE.name); + variable.value = args.VALUE; + } -Scratch3DataBlocks.prototype.setVariableTo = function (args, util) { - var variable = util.target.lookupOrCreateVariable(args.VARIABLE); - variable.value = 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); + variable.value = castedValue + dValue; + } -Scratch3DataBlocks.prototype.changeVariableBy = function (args, util) { - var variable = util.target.lookupOrCreateVariable(args.VARIABLE); - var castedValue = Cast.toNumber(variable.value); - var dValue = Cast.toNumber(args.VALUE); - variable.value = castedValue + dValue; -}; - -Scratch3DataBlocks.prototype.getListContents = function (args, util) { - var list = util.target.lookupOrCreateList(args.LIST); - // 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. - var allSingleLetters = true; - for (var i = 0; i < list.contents.length; i++) { - var listItem = list.contents[i]; - if (!((typeof listItem === 'string') && - (listItem.length == 1))) { - allSingleLetters = false; - break; + getListContents (args, util) { + const list = util.target.lookupOrCreateList( + args.LIST.id, args.LIST.name); + // 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.contents.join(''); - } else { - return list.contents.join(' '); - } -}; + if (allSingleLetters) { + return list.value.join(''); + } + return list.value.join(' '); -Scratch3DataBlocks.prototype.addToList = function (args, util) { - var list = util.target.lookupOrCreateList(args.LIST); - list.contents.push(args.ITEM); -}; - -Scratch3DataBlocks.prototype.deleteOfList = function (args, util) { - var list = util.target.lookupOrCreateList(args.LIST); - var index = Cast.toListIndex(args.INDEX, list.contents.length); - if (index === Cast.LIST_INVALID) { - return; - } else if (index === Cast.LIST_ALL) { - list.contents = []; - return; } - list.contents.splice(index - 1, 1); -}; -Scratch3DataBlocks.prototype.insertAtList = function (args, util) { - var item = args.ITEM; - var list = util.target.lookupOrCreateList(args.LIST); - var index = Cast.toListIndex(args.INDEX, list.contents.length + 1); - if (index === Cast.LIST_INVALID) { - return; + addToList (args, util) { + const list = util.target.lookupOrCreateList( + args.LIST.id, args.LIST.name); + list.value.push(args.ITEM); } - list.contents.splice(index - 1, 0, item); -}; -Scratch3DataBlocks.prototype.replaceItemOfList = function (args, util) { - var item = args.ITEM; - var list = util.target.lookupOrCreateList(args.LIST); - var index = Cast.toListIndex(args.INDEX, list.contents.length); - if (index === Cast.LIST_INVALID) { - return; + deleteOfList (args, util) { + const list = util.target.lookupOrCreateList( + args.LIST.id, args.LIST.name); + const index = Cast.toListIndex(args.INDEX, list.value.length); + if (index === Cast.LIST_INVALID) { + return; + } else if (index === Cast.LIST_ALL) { + list.value = []; + return; + } + list.value.splice(index - 1, 1); } - list.contents.splice(index - 1, 1, item); -}; -Scratch3DataBlocks.prototype.getItemOfList = function (args, util) { - var list = util.target.lookupOrCreateList(args.LIST); - var index = Cast.toListIndex(args.INDEX, list.contents.length); - if (index === Cast.LIST_INVALID) { - 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); + if (index === Cast.LIST_INVALID) { + return; + } + list.value.splice(index - 1, 0, item); } - return list.contents[index - 1]; -}; -Scratch3DataBlocks.prototype.lengthOfList = function (args, util) { - var list = util.target.lookupOrCreateList(args.LIST); - return list.contents.length; -}; - -Scratch3DataBlocks.prototype.listContainsItem = function (args, util) { - var item = args.ITEM; - var list = util.target.lookupOrCreateList(args.LIST); - if (list.contents.indexOf(item) >= 0) { - return true; + 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); + if (index === Cast.LIST_INVALID) { + return; + } + list.value.splice(index - 1, 1, item); } - // Try using Scratch comparison operator on each item. - // (Scratch considers the string '123' equal to the number 123). - for (var i = 0; i < list.contents.length; i++) { - if (Cast.compare(list.contents[i], item) == 0) { + + getItemOfList (args, util) { + const list = util.target.lookupOrCreateList( + args.LIST.id, args.LIST.name); + const index = Cast.toListIndex(args.INDEX, list.value.length); + if (index === Cast.LIST_INVALID) { + return ''; + } + return list.value[index - 1]; + } + + 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; } - return false; -}; +} module.exports = Scratch3DataBlocks; diff --git a/src/blocks/scratch3_event.js b/src/blocks/scratch3_event.js index 7f56e3ba..5ee2fab1 100644 --- a/src/blocks/scratch3_event.js +++ b/src/blocks/scratch3_event.js @@ -1,89 +1,97 @@ -var Cast = require('../util/cast'); +const Cast = require('../util/cast'); + +class Scratch3EventBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } -function Scratch3EventBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; + getPrimitives () { + return { + 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_whenbackdropswitchesto: { + restartExistingThreads: true + }, + event_whengreaterthan: { + restartExistingThreads: false, + edgeActivated: true + }, + event_whenbroadcastreceived: { + restartExistingThreads: true + } + }; + } + + hatGreaterThanPredicate (args, util) { + const option = Cast.toString(args.WHENGREATERTHANMENU).toLowerCase(); + const value = Cast.toNumber(args.VALUE); + // @todo: Other cases :) + if (option === 'timer') { + return util.ioQuery('clock', 'projectTimer') > 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) { + const broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg( + args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name); + if (broadcastVar) { + const broadcastOption = 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; + const waiting = util.stackFrame.startedThreads.some(thread => instance.runtime.isActiveThread(thread)); + if (waiting) { + util.yield(); + } + } + } } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3EventBlocks.prototype.getPrimitives = function() { - return { - 'event_broadcast': this.broadcast, - 'event_broadcastandwait': this.broadcastAndWait, - 'event_whengreaterthan': this.hatGreaterThanPredicate - }; -}; - -Scratch3EventBlocks.prototype.getHats = function () { - return { - 'event_whenflagclicked': { - restartExistingThreads: true - }, - 'event_whenkeypressed': { - restartExistingThreads: false - }, - 'event_whenthisspriteclicked': { - restartExistingThreads: true - }, - 'event_whenbackdropswitchesto': { - restartExistingThreads: true - }, - 'event_whengreaterthan': { - restartExistingThreads: false, - edgeActivated: true - }, - 'event_whenbroadcastreceived': { - restartExistingThreads: true - } - }; -}; - -Scratch3EventBlocks.prototype.hatGreaterThanPredicate = function (args, util) { - var option = Cast.toString(args.WHENGREATERTHANMENU).toLowerCase(); - var value = Cast.toNumber(args.VALUE); - // @todo: Other cases :) - if (option == 'timer') { - return util.ioQuery('clock', 'projectTimer') > value; - } - return false; -}; - -Scratch3EventBlocks.prototype.broadcast = function(args, util) { - var broadcastOption = Cast.toString(args.BROADCAST_OPTION); - util.startHats('event_whenbroadcastreceived', { - 'BROADCAST_OPTION': broadcastOption - }); -}; - -Scratch3EventBlocks.prototype.broadcastAndWait = function (args, util) { - var broadcastOption = Cast.toString(args.BROADCAST_OPTION); - // 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. - var instance = this; - var waiting = util.stackFrame.startedThreads.some(function(thread) { - return instance.runtime.isActiveThread(thread); - }); - if (waiting) { - util.yield(); - } -}; - module.exports = Scratch3EventBlocks; diff --git a/src/blocks/scratch3_looks.js b/src/blocks/scratch3_looks.js index a1d433e9..e173ca04 100644 --- a/src/blocks/scratch3_looks.js +++ b/src/blocks/scratch3_looks.js @@ -1,221 +1,452 @@ -var Cast = require('../util/cast'); +const Cast = require('../util/cast'); +const Clone = require('../util/clone'); +const RenderedTarget = require('../sprites/rendered-target'); + +/** + * @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 {Boolean} drawableVisible - false if drawable has been hidden by blank text. + * See _renderBubble for explanation of this optimization. + * @property {string} text - the text of the bubble. + * @property {string} type - the type of the bubble, "say" or "think" + */ + +class Scratch3LooksBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + this._onTargetMoved = this._onTargetMoved.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('SAY', this._updateBubble); + } -function Scratch3LooksBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * The default bubble state, to be used when a target has no existing bubble state. + * @type {BubbleState} */ - this.runtime = runtime; -} + static get DEFAULT_BUBBLE_STATE () { + return { + drawableId: null, + drawableVisible: true, + onSpriteRight: true, + skinId: null, + text: '', + type: 'say' + }; + } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3LooksBlocks.prototype.getPrimitives = function() { - return { - 'looks_say': this.say, - 'looks_sayforsecs': this.sayforsecs, - 'looks_think': this.think, - 'looks_thinkforsecs': this.sayforsecs, - 'looks_show': this.show, - 'looks_hide': this.hide, - '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_gotofront': this.goToFront, - 'looks_gobacklayers': this.goBackLayers, - 'looks_size': this.getSize, - 'looks_costumeorder': this.getCostumeIndex, - 'looks_backdroporder': this.getBackdropIndex, - 'looks_backdropname': this.getBackdropName - }; -}; + /** + * The key to load & store a target's bubble-related state. + * @type {string} + */ + static get STATE_KEY () { + return 'Scratch.looks'; + } -Scratch3LooksBlocks.prototype.say = function (args, util) { - util.target.setSay('say', args.MESSAGE); -}; + /** + * @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; + } -Scratch3LooksBlocks.prototype.sayforsecs = function (args, util) { - util.target.setSay('say', args.MESSAGE); - return new Promise(function(resolve) { - setTimeout(function() { - // Clear say bubble and proceed. - util.target.setSay(); - resolve(); - }, 1000 * args.SECS); - }); -}; + /** + * Handle a target which has moved. + * @param {RenderedTarget} target - the target which has moved. + * @private + */ + _onTargetMoved (target) { + const bubbleState = this._getBubbleState(target); + if (bubbleState.drawableId) { + this._positionBubble(target); + } + } -Scratch3LooksBlocks.prototype.think = function (args, util) { - util.target.setSay('think', args.MESSAGE); -}; + /** + * 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); + this.runtime.renderer.destroySkin(bubbleState.skinId); + bubbleState.drawableId = null; + bubbleState.skinId = null; + bubbleState.drawableVisible = true; // Reset back to default value + this.runtime.requestRedraw(); + } + target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } -Scratch3LooksBlocks.prototype.thinkforsecs = function (args, util) { - util.target.setSay('think', args.MESSAGE); - return new Promise(function(resolve) { - setTimeout(function() { - // Clear say bubble and proceed. - util.target.setSay(); - resolve(); - }, 1000 * args.SECS); - }); -}; + /** + * Handle project start/stop by clearing all visible bubbles. + * @private + */ + _onResetBubbles () { + for (let n = 0; n < this.runtime.targets.length; n++) { + this._onTargetWillExit(this.runtime.targets[n]); + } + clearTimeout(this._bubbleTimeout); + } -Scratch3LooksBlocks.prototype.show = function (args, util) { - util.target.setVisible(true); -}; - -Scratch3LooksBlocks.prototype.hide = function (args, util) { - util.target.setVisible(false); -}; - -/** - * Utility function to set the costume or backdrop of a target. - * Matches the behavior of Scratch 2.0 for different types of arguments. - * @param {!Target} target Target to set costume/backdrop to. - * @param {Any} requestedCostume Costume requested, e.g., 0, 'name', etc. - * @param {boolean=} opt_zeroIndex Set to zero-index the requestedCostume. - * @return {Array.} Any threads started by this switch. - */ -Scratch3LooksBlocks.prototype._setCostumeOrBackdrop = function (target, - requestedCostume, opt_zeroIndex) { - if (typeof requestedCostume === 'number') { - target.setCostume(opt_zeroIndex ? - requestedCostume : requestedCostume - 1); - } else { - var costumeIndex = target.getCostumeIndexByName(requestedCostume); - if (costumeIndex > -1) { - target.setCostume(costumeIndex); - } else if (costumeIndex == 'previous costume' || - costumeIndex == 'previous backdrop') { - target.setCostume(target.currentCostume - 1); - } else if (costumeIndex == 'next costume' || - costumeIndex == 'next backdrop') { - target.setCostume(target.currentCostume + 1); + /** + * 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) { + const bubbleState = this._getBubbleState(target); + const [bubbleWidth, bubbleHeight] = this.runtime.renderer.getSkinSize(bubbleState.drawableId); + const targetBounds = target.getBounds(); + const stageBounds = this.runtime.getTargetForStage().getBounds(); + 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 { - var forcedNumber = Cast.toNumber(requestedCostume); - if (!isNaN(forcedNumber)) { - target.setCostume(opt_zeroIndex ? - forcedNumber : forcedNumber - 1); + this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, { + position: [ + bubbleState.onSpriteRight ? ( + Math.min(stageBounds.right - bubbleWidth, targetBounds.right) + ) : ( + Math.max(stageBounds.left, targetBounds.left - bubbleWidth) + ), + Math.min(stageBounds.top, targetBounds.top + 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) { + const bubbleState = this._getBubbleState(target); + const {drawableVisible, type, text, onSpriteRight} = bubbleState; + + // Remove the bubble if target is not visible, or text is being set to blank + // without being initialized. See comment below about blank text optimization. + if (!target.visible || (text === '' && !bubbleState.skinId)) { + return this._onTargetWillExit(target); + } + + if (bubbleState.skinId) { + // Optimization: if text is set to blank, hide the drawable instead of + // getting rid of it. This prevents flickering in "typewriter" projects + if ((text === '' && drawableVisible) || (text !== '' && !drawableVisible)) { + bubbleState.drawableVisible = text !== ''; + this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, { + visible: bubbleState.drawableVisible + }); + } + if (bubbleState.drawableVisible) { + this.runtime.renderer.updateTextSkin(bubbleState.skinId, type, text, onSpriteRight, [0, 0]); + } + } else { + target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + + // TODO is there a way to figure out before rendering whether to default left or right? + const targetBounds = target.getBounds(); + const stageBounds = this.runtime.getTargetForStage().getBounds(); + if (targetBounds.right + 170 > stageBounds.right) { + bubbleState.onSpriteRight = false; + } + + bubbleState.drawableId = this.runtime.renderer.createDrawable(); + bubbleState.skinId = this.runtime.renderer.createTextSkin(type, text, bubbleState.onSpriteRight, [0, 0]); + + this.runtime.renderer.setDrawableOrder(bubbleState.drawableId, Infinity); + this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, { + skinId: bubbleState.skinId + }); + } + + this._positionBubble(target); + } + + /** + * 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 = text; + this._renderBubble(target); + } + + /** + * Retrieve the block primitives implemented by this package. + * @return {object.} 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_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_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}, + looks_costumenumbername: {isSpriteSpecific: true}, + looks_backdropnumbername: {} + }; + } + + say (args, util) { + // @TODO in 2.0 calling say/think resets the right/left bias of the bubble + this._updateBubble(util.target, 'say', String(args.MESSAGE)); + } + + sayforsecs (args, util) { + this.say(args, util); + const _target = util.target; + return new Promise(resolve => { + this._bubbleTimeout = setTimeout(() => { + this._bubbleTimeout = null; + // Clear say bubble and proceed. + this._updateBubble(_target, 'say', ''); + resolve(); + }, 1000 * args.SECS); + }); + } + + think (args, util) { + this._updateBubble(util.target, 'think', String(args.MESSAGE)); + } + + thinkforsecs (args, util) { + this.think(args, util); + const _target = util.target; + return new Promise(resolve => { + this._bubbleTimeout = setTimeout(() => { + this._bubbleTimeout = null; + // Clear say bubble and proceed. + 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 or backdrop of a target. + * Matches the behavior of Scratch 2.0 for different types of arguments. + * @param {!Target} target Target to set costume/backdrop to. + * @param {Any} requestedCostume Costume requested, e.g., 0, 'name', etc. + * @param {boolean=} optZeroIndex Set to zero-index the requestedCostume. + * @return {Array.} Any threads started by this switch. + */ + _setCostumeOrBackdrop (target, + requestedCostume, optZeroIndex) { + if (typeof requestedCostume === 'number') { + target.setCostume(optZeroIndex ? + requestedCostume : requestedCostume - 1); + } else { + const costumeIndex = target.getCostumeIndexByName(requestedCostume); + if (costumeIndex > -1) { + target.setCostume(costumeIndex); + } else if (requestedCostume === 'previous costume' || + requestedCostume === 'previous backdrop') { + target.setCostume(target.currentCostume - 1); + } else if (requestedCostume === 'next costume' || + requestedCostume === 'next backdrop') { + target.setCostume(target.currentCostume + 1); + } else { + const forcedNumber = Number(requestedCostume); + if (!isNaN(forcedNumber)) { + target.setCostume(optZeroIndex ? + forcedNumber : forcedNumber - 1); + } + } + } + if (target === this.runtime.getTargetForStage()) { + // Target is the stage - start hats. + const newName = target.sprite.costumes[target.currentCostume].name; + return this.runtime.startHats('event_whenbackdropswitchesto', { + BACKDROP: newName + }); + } + return []; + } + + switchCostume (args, util) { + this._setCostumeOrBackdrop(util.target, args.COSTUME); + } + + nextCostume (args, util) { + this._setCostumeOrBackdrop( + util.target, util.target.currentCostume + 1, true + ); + } + + switchBackdrop (args) { + this._setCostumeOrBackdrop(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._setCostumeOrBackdrop( + 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; + const waiting = util.stackFrame.startedThreads.some(thread => instance.runtime.isActiveThread(thread)); + if (waiting) { + util.yield(); + } + } + + nextBackdrop () { + const stage = this.runtime.getTargetForStage(); + this._setCostumeOrBackdrop( + stage, stage.currentCostume + 1, true + ); + } + + changeEffect (args, util) { + const effect = Cast.toString(args.EFFECT).toLowerCase(); + const change = Cast.toNumber(args.CHANGE); + if (!util.target.effects.hasOwnProperty(effect)) return; + const newValue = change + util.target.effects[effect]; + util.target.setEffect(effect, newValue); + } + + setEffect (args, util) { + const effect = Cast.toString(args.EFFECT).toLowerCase(); + const value = Cast.toNumber(args.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(); } } } - if (target == this.runtime.getTargetForStage()) { - // Target is the stage - start hats. - var newName = target.sprite.costumes[target.currentCostume].name; - return this.runtime.startHats('event_whenbackdropswitchesto', { - 'BACKDROP': newName - }); - } - return []; -}; -Scratch3LooksBlocks.prototype.switchCostume = function (args, util) { - this._setCostumeOrBackdrop(util.target, args.COSTUME); -}; - -Scratch3LooksBlocks.prototype.nextCostume = function (args, util) { - this._setCostumeOrBackdrop( - util.target, util.target.currentCostume + 1, true - ); -}; - -Scratch3LooksBlocks.prototype.switchBackdrop = function (args) { - this._setCostumeOrBackdrop(this.runtime.getTargetForStage(), args.BACKDROP); -}; - -Scratch3LooksBlocks.prototype.switchBackdropAndWait = function (args, util) { - // Have we run before, starting threads? - if (!util.stackFrame.startedThreads) { - // No - switch the backdrop. - util.stackFrame.startedThreads = ( - this._setCostumeOrBackdrop( - this.runtime.getTargetForStage(), - args.BACKDROP - ) - ); - if (util.stackFrame.startedThreads.length == 0) { - // Nothing was started. - return; + 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)); + } } } - // We've run before; check if the wait is still going on. - var instance = this; - var waiting = util.stackFrame.startedThreads.some(function(thread) { - return instance.runtime.isActiveThread(thread); - }); - if (waiting) { - util.yield(); + + getSize (args, util) { + return Math.round(util.target.size); } -}; -Scratch3LooksBlocks.prototype.nextBackdrop = function () { - var stage = this.runtime.getTargetForStage(); - this._setCostumeOrBackdrop( - stage, stage.currentCostume + 1, true - ); -}; + getBackdropNumberName (args) { + const stage = this.runtime.getTargetForStage(); + if (args.NUMBER_NAME === 'number') { + return stage.currentCostume + 1; + } + // Else return name + return stage.sprite.costumes[stage.currentCostume].name; + } -Scratch3LooksBlocks.prototype.changeEffect = function (args, util) { - var effect = Cast.toString(args.EFFECT).toLowerCase(); - var change = Cast.toNumber(args.CHANGE); - if (!util.target.effects.hasOwnProperty(effect)) return; - var newValue = change + util.target.effects[effect]; - util.target.setEffect(effect, newValue); -}; - -Scratch3LooksBlocks.prototype.setEffect = function (args, util) { - var effect = Cast.toString(args.EFFECT).toLowerCase(); - var value = Cast.toNumber(args.VALUE); - util.target.setEffect(effect, value); -}; - -Scratch3LooksBlocks.prototype.clearEffects = function (args, util) { - util.target.clearEffects(); -}; - -Scratch3LooksBlocks.prototype.changeSize = function (args, util) { - var change = Cast.toNumber(args.CHANGE); - util.target.setSize(util.target.size + change); -}; - -Scratch3LooksBlocks.prototype.setSize = function (args, util) { - var size = Cast.toNumber(args.SIZE); - util.target.setSize(size); -}; - -Scratch3LooksBlocks.prototype.goToFront = function (args, util) { - util.target.goToFront(); -}; - -Scratch3LooksBlocks.prototype.goBackLayers = function (args, util) { - util.target.goBackLayers(args.NUM); -}; - -Scratch3LooksBlocks.prototype.getSize = function (args, util) { - return util.target.size; -}; - -Scratch3LooksBlocks.prototype.getBackdropIndex = function () { - var stage = this.runtime.getTargetForStage(); - return stage.currentCostume + 1; -}; - -Scratch3LooksBlocks.prototype.getBackdropName = function () { - var stage = this.runtime.getTargetForStage(); - return stage.sprite.costumes[stage.currentCostume].name; -}; - -Scratch3LooksBlocks.prototype.getCostumeIndex = function (args, util) { - return util.target.currentCostume + 1; -}; + getCostumeNumberName (args, util) { + if (args.NUMBER_NAME === 'number') { + return util.target.currentCostume + 1; + } + // Else return name + return util.target.sprite.costumes[util.target.currentCostume].name; + } +} module.exports = Scratch3LooksBlocks; diff --git a/src/blocks/scratch3_motion.js b/src/blocks/scratch3_motion.js index 68078578..524eab24 100644 --- a/src/blocks/scratch3_motion.js +++ b/src/blocks/scratch3_motion.js @@ -1,234 +1,259 @@ -var Cast = require('../util/cast'); -var MathUtil = require('../util/math-util'); -var Timer = require('../util/timer'); +const Cast = require('../util/cast'); +const MathUtil = require('../util/math-util'); +const Timer = require('../util/timer'); + +class Scratch3MotionBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } -function Scratch3MotionBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; -} - -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3MotionBlocks.prototype.getPrimitives = function() { - 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_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 - }; -}; - -Scratch3MotionBlocks.prototype.moveSteps = function (args, util) { - var steps = Cast.toNumber(args.STEPS); - var radians = MathUtil.degToRad(90 - util.target.direction); - var dx = steps * Math.cos(radians); - var dy = steps * Math.sin(radians); - util.target.setXY(util.target.x + dx, util.target.y + dy); -}; - -Scratch3MotionBlocks.prototype.goToXY = function (args, util) { - var x = Cast.toNumber(args.X); - var y = Cast.toNumber(args.Y); - util.target.setXY(x, y); -}; - -Scratch3MotionBlocks.prototype.goTo = function (args, util) { - var targetX = 0; - var targetY = 0; - if (args.TO === '_mouse_') { - targetX = util.ioQuery('mouse', 'getX'); - targetY = util.ioQuery('mouse', 'getY'); - } else if (args.TO === '_random_') { - var stageWidth = this.runtime.constructor.STAGE_WIDTH; - var stageHeight = this.runtime.constructor.STAGE_HEIGHT; - targetX = Math.round(stageWidth * (Math.random() - 0.5)); - targetY = Math.round(stageHeight * (Math.random() - 0.5)); - } else { - var goToTarget = this.runtime.getSpriteTargetByName(args.TO); - if (!goToTarget) return; - targetX = goToTarget.x; - targetY = goToTarget.y; - } - util.target.setXY(targetX, targetY); -}; - -Scratch3MotionBlocks.prototype.turnRight = function (args, util) { - var degrees = Cast.toNumber(args.DEGREES); - util.target.setDirection(util.target.direction + degrees); -}; - -Scratch3MotionBlocks.prototype.turnLeft = function (args, util) { - var degrees = Cast.toNumber(args.DEGREES); - util.target.setDirection(util.target.direction - degrees); -}; - -Scratch3MotionBlocks.prototype.pointInDirection = function (args, util) { - var direction = Cast.toNumber(args.DIRECTION); - util.target.setDirection(direction); -}; - -Scratch3MotionBlocks.prototype.pointTowards = function (args, util) { - var targetX = 0; - var targetY = 0; - if (args.TOWARDS === '_mouse_') { - targetX = util.ioQuery('mouse', 'getX'); - targetY = util.ioQuery('mouse', 'getY'); - } else { - var pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS); - if (!pointTarget) return; - targetX = pointTarget.x; - targetY = pointTarget.y; + 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 + }; } - var dx = targetX - util.target.x; - var dy = targetY - util.target.y; - var direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx)); - util.target.setDirection(direction); -}; + getMonitored () { + return { + motion_xposition: {isSpriteSpecific: true}, + motion_yposition: {isSpriteSpecific: true}, + motion_direction: {isSpriteSpecific: true} + }; + } -Scratch3MotionBlocks.prototype.glide = function (args, util) { - if (!util.stackFrame.timer) { - // First time: save data for future use. - util.stackFrame.timer = new Timer(); - util.stackFrame.timer.start(); - util.stackFrame.duration = Cast.toNumber(args.SECS); - util.stackFrame.startX = util.target.x; - util.stackFrame.startY = util.target.y; - util.stackFrame.endX = Cast.toNumber(args.X); - util.stackFrame.endY = Cast.toNumber(args.Y); - if (util.stackFrame.duration <= 0) { - // Duration too short to glide. - util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); + moveSteps (args, util) { + const steps = Cast.toNumber(args.STEPS); + const radians = MathUtil.degToRad(90 - util.target.direction); + const dx = steps * Math.cos(radians); + const dy = steps * Math.sin(radians); + util.target.setXY(util.target.x + dx, util.target.y + dy); + } + + goToXY (args, util) { + const x = Cast.toNumber(args.X); + const y = Cast.toNumber(args.Y); + util.target.setXY(x, y); + } + + getTargetXY (targetName, util) { + let targetX = 0; + let targetY = 0; + if (targetName === '_mouse_') { + targetX = util.ioQuery('mouse', 'getScratchX'); + targetY = util.ioQuery('mouse', 'getScratchY'); + } else if (targetName === '_random_') { + const stageWidth = this.runtime.constructor.STAGE_WIDTH; + const stageHeight = this.runtime.constructor.STAGE_HEIGHT; + targetX = Math.round(stageWidth * (Math.random() - 0.5)); + targetY = Math.round(stageHeight * (Math.random() - 0.5)); + } else { + const goToTarget = this.runtime.getSpriteTargetByName(targetName); + if (!goToTarget) return; + targetX = goToTarget.x; + targetY = goToTarget.y; + } + return [targetX, targetY]; + } + + goTo (args, util) { + const targetXY = this.getTargetXY(args.TO, util); + if (targetXY) { + util.target.setXY(targetXY[0], targetXY[1]); + } + } + + turnRight (args, util) { + const degrees = Cast.toNumber(args.DEGREES); + util.target.setDirection(util.target.direction + degrees); + } + + turnLeft (args, util) { + const degrees = Cast.toNumber(args.DEGREES); + util.target.setDirection(util.target.direction - degrees); + } + + pointInDirection (args, util) { + const direction = Cast.toNumber(args.DIRECTION); + util.target.setDirection(direction); + } + + pointTowards (args, util) { + let targetX = 0; + let targetY = 0; + if (args.TOWARDS === '_mouse_') { + targetX = util.ioQuery('mouse', 'getScratchX'); + targetY = util.ioQuery('mouse', 'getScratchY'); + } else { + const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS); + if (!pointTarget) return; + targetX = pointTarget.x; + targetY = pointTarget.y; + } + + const dx = targetX - util.target.x; + const dy = targetY - util.target.y; + const direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx)); + util.target.setDirection(direction); + } + + glide (args, util) { + if (util.stackFrame.timer) { + const timeElapsed = util.stackFrame.timer.timeElapsed(); + if (timeElapsed < util.stackFrame.duration * 1000) { + // In progress: move to intermediate position. + const frac = timeElapsed / (util.stackFrame.duration * 1000); + const dx = frac * (util.stackFrame.endX - util.stackFrame.startX); + const dy = frac * (util.stackFrame.endY - util.stackFrame.startY); + util.target.setXY( + util.stackFrame.startX + dx, + util.stackFrame.startY + dy + ); + util.yield(); + } else { + // Finished: move to final position. + util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); + } + } else { + // First time: save data for future use. + util.stackFrame.timer = new Timer(); + util.stackFrame.timer.start(); + util.stackFrame.duration = Cast.toNumber(args.SECS); + util.stackFrame.startX = util.target.x; + util.stackFrame.startY = util.target.y; + util.stackFrame.endX = Cast.toNumber(args.X); + util.stackFrame.endY = Cast.toNumber(args.Y); + if (util.stackFrame.duration <= 0) { + // Duration too short to glide. + util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); + return; + } + util.yield(); + } + } + + glideTo (args, util) { + const targetXY = this.getTargetXY(args.TO, util); + if (targetXY) { + this.glide({SECS: args.SECS, X: targetXY[0], Y: targetXY[1]}, util); + } + } + + ifOnEdgeBounce (args, util) { + const bounds = util.target.getBounds(); + if (!bounds) { return; } - util.yield(); - } else { - var timeElapsed = util.stackFrame.timer.timeElapsed(); - if (timeElapsed < util.stackFrame.duration * 1000) { - // In progress: move to intermediate position. - var frac = timeElapsed / (util.stackFrame.duration * 1000); - var dx = frac * (util.stackFrame.endX - util.stackFrame.startX); - var dy = frac * (util.stackFrame.endY - util.stackFrame.startY); - util.target.setXY( - util.stackFrame.startX + dx, - util.stackFrame.startY + dy - ); - util.yield(); - } else { - // Finished: move to final position. - util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); + // Measure distance to edges. + // Values are positive when the sprite is far away, + // and clamped to zero when the sprite is beyond. + const stageWidth = this.runtime.constructor.STAGE_WIDTH; + const stageHeight = this.runtime.constructor.STAGE_HEIGHT; + const distLeft = Math.max(0, (stageWidth / 2) + bounds.left); + const distTop = Math.max(0, (stageHeight / 2) - bounds.top); + const distRight = Math.max(0, (stageWidth / 2) - bounds.right); + const distBottom = Math.max(0, (stageHeight / 2) + bounds.bottom); + // Find the nearest edge. + let nearestEdge = ''; + let minDist = Infinity; + if (distLeft < minDist) { + minDist = distLeft; + nearestEdge = 'left'; } + if (distTop < minDist) { + minDist = distTop; + nearestEdge = 'top'; + } + if (distRight < minDist) { + minDist = distRight; + nearestEdge = 'right'; + } + if (distBottom < minDist) { + minDist = distBottom; + nearestEdge = 'bottom'; + } + if (minDist > 0) { + return; // Not touching any edge. + } + // Point away from the nearest edge. + const radians = MathUtil.degToRad(90 - util.target.direction); + let dx = Math.cos(radians); + let dy = -Math.sin(radians); + if (nearestEdge === 'left') { + dx = Math.max(0.2, Math.abs(dx)); + } else if (nearestEdge === 'top') { + dy = Math.max(0.2, Math.abs(dy)); + } else if (nearestEdge === 'right') { + dx = 0 - Math.max(0.2, Math.abs(dx)); + } else if (nearestEdge === 'bottom') { + dy = 0 - Math.max(0.2, Math.abs(dy)); + } + const newDirection = MathUtil.radToDeg(Math.atan2(dy, dx)) + 90; + util.target.setDirection(newDirection); + // Keep within the stage. + const fencedPosition = util.target.keepInFence(util.target.x, util.target.y); + util.target.setXY(fencedPosition[0], fencedPosition[1]); } -}; -Scratch3MotionBlocks.prototype.ifOnEdgeBounce = function (args, util) { - var bounds = util.target.getBounds(); - if (!bounds) { - return; + setRotationStyle (args, util) { + util.target.setRotationStyle(args.STYLE); } - // Measure distance to edges. - // Values are positive when the sprite is far away, - // and clamped to zero when the sprite is beyond. - var stageWidth = this.runtime.constructor.STAGE_WIDTH; - var stageHeight = this.runtime.constructor.STAGE_HEIGHT; - var distLeft = Math.max(0, stageWidth / 2 + bounds.left); - var distTop = Math.max(0, stageHeight / 2 - bounds.top); - var distRight = Math.max(0, stageWidth / 2 - bounds.right); - var distBottom = Math.max(0, stageHeight / 2 + bounds.bottom); - // Find the nearest edge. - var nearestEdge = ''; - var minDist = Infinity; - if (distLeft < minDist) { - minDist = distLeft; - nearestEdge = 'left'; + + changeX (args, util) { + const dx = Cast.toNumber(args.DX); + util.target.setXY(util.target.x + dx, util.target.y); } - if (distTop < minDist) { - minDist = distTop; - nearestEdge = 'top'; + + setX (args, util) { + const x = Cast.toNumber(args.X); + util.target.setXY(x, util.target.y); } - if (distRight < minDist) { - minDist = distRight; - nearestEdge = 'right'; + + changeY (args, util) { + const dy = Cast.toNumber(args.DY); + util.target.setXY(util.target.x, util.target.y + dy); } - if (distBottom < minDist) { - minDist = distBottom; - nearestEdge = 'bottom'; + + setY (args, util) { + const y = Cast.toNumber(args.Y); + util.target.setXY(util.target.x, y); } - if (minDist > 0) { - return; // Not touching any edge. + + getX (args, util) { + return util.target.x; } - // Point away from the nearest edge. - var radians = MathUtil.degToRad(90 - util.target.direction); - var dx = Math.cos(radians); - var dy = -Math.sin(radians); - if (nearestEdge == 'left') { - dx = Math.max(0.2, Math.abs(dx)); - } else if (nearestEdge == 'top') { - dy = Math.max(0.2, Math.abs(dy)); - } else if (nearestEdge == 'right') { - dx = 0 - Math.max(0.2, Math.abs(dx)); - } else if (nearestEdge == 'bottom') { - dy = 0 - Math.max(0.2, Math.abs(dy)); + + getY (args, util) { + return util.target.y; } - var newDirection = MathUtil.radToDeg(Math.atan2(dy, dx)) + 90; - util.target.setDirection(newDirection); - // Keep within the stage. - var fencedPosition = util.target.keepInFence(util.target.x, util.target.y); - util.target.setXY(fencedPosition[0], fencedPosition[1]); -}; -Scratch3MotionBlocks.prototype.setRotationStyle = function (args, util) { - util.target.setRotationStyle(args.STYLE); -}; - -Scratch3MotionBlocks.prototype.changeX = function (args, util) { - var dx = Cast.toNumber(args.DX); - util.target.setXY(util.target.x + dx, util.target.y); -}; - -Scratch3MotionBlocks.prototype.setX = function (args, util) { - var x = Cast.toNumber(args.X); - util.target.setXY(x, util.target.y); -}; - -Scratch3MotionBlocks.prototype.changeY = function (args, util) { - var dy = Cast.toNumber(args.DY); - util.target.setXY(util.target.x, util.target.y + dy); -}; - -Scratch3MotionBlocks.prototype.setY = function (args, util) { - var y = Cast.toNumber(args.Y); - util.target.setXY(util.target.x, y); -}; - -Scratch3MotionBlocks.prototype.getX = function (args, util) { - return util.target.x; -}; - -Scratch3MotionBlocks.prototype.getY = function (args, util) { - return util.target.y; -}; - -Scratch3MotionBlocks.prototype.getDirection = function (args, util) { - return util.target.direction; -}; + getDirection (args, util) { + return util.target.direction; + } +} module.exports = Scratch3MotionBlocks; diff --git a/src/blocks/scratch3_operators.js b/src/blocks/scratch3_operators.js index 0d553177..c6ee70be 100644 --- a/src/blocks/scratch3_operators.js +++ b/src/blocks/scratch3_operators.js @@ -1,143 +1,154 @@ -var Cast = require('../util/cast.js'); +const Cast = require('../util/cast.js'); +const MathUtil = require('../util/math-util.js'); + +class Scratch3OperatorsBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } -function Scratch3OperatorsBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; + getPrimitives () { + return { + operator_add: this.add, + operator_subtract: this.subtract, + operator_multiply: this.multiply, + operator_divide: this.divide, + operator_lt: this.lt, + operator_equals: this.equals, + operator_gt: this.gt, + operator_and: this.and, + operator_or: this.or, + operator_not: this.not, + operator_random: this.random, + operator_join: this.join, + operator_letter_of: this.letterOf, + operator_length: this.length, + operator_contains: this.contains, + operator_mod: this.mod, + operator_round: this.round, + operator_mathop: this.mathop + }; + } + + add (args) { + return Cast.toNumber(args.NUM1) + Cast.toNumber(args.NUM2); + } + + subtract (args) { + return Cast.toNumber(args.NUM1) - Cast.toNumber(args.NUM2); + } + + multiply (args) { + return Cast.toNumber(args.NUM1) * Cast.toNumber(args.NUM2); + } + + divide (args) { + return Cast.toNumber(args.NUM1) / Cast.toNumber(args.NUM2); + } + + lt (args) { + return Cast.compare(args.OPERAND1, args.OPERAND2) < 0; + } + + equals (args) { + return Cast.compare(args.OPERAND1, args.OPERAND2) === 0; + } + + gt (args) { + return Cast.compare(args.OPERAND1, args.OPERAND2) > 0; + } + + and (args) { + return Cast.toBoolean(args.OPERAND1) && Cast.toBoolean(args.OPERAND2); + } + + or (args) { + return Cast.toBoolean(args.OPERAND1) || Cast.toBoolean(args.OPERAND2); + } + + not (args) { + return !Cast.toBoolean(args.OPERAND); + } + + random (args) { + const nFrom = Cast.toNumber(args.FROM); + const nTo = Cast.toNumber(args.TO); + const low = nFrom <= nTo ? nFrom : nTo; + const high = nFrom <= nTo ? nTo : nFrom; + if (low === high) return low; + // If both arguments are ints, truncate the result to an int. + if (Cast.isInt(args.FROM) && Cast.isInt(args.TO)) { + return low + Math.floor(Math.random() * ((high + 1) - low)); + } + return (Math.random() * (high - low)) + low; + } + + join (args) { + return Cast.toString(args.STRING1) + Cast.toString(args.STRING2); + } + + letterOf (args) { + const index = Cast.toNumber(args.LETTER) - 1; + const str = Cast.toString(args.STRING); + // Out of bounds? + if (index < 0 || index >= str.length) { + return ''; + } + return str.charAt(index); + } + + length (args) { + return Cast.toString(args.STRING).length; + } + + contains (args) { + const format = function (string) { + return Cast.toString(string).toLowerCase(); + }; + return format(args.STRING1).includes(format(args.STRING2)); + } + + mod (args) { + const n = Cast.toNumber(args.NUM1); + const modulus = Cast.toNumber(args.NUM2); + let result = n % modulus; + // Scratch mod is kept positive. + if (result / modulus < 0) result += modulus; + return result; + } + + round (args) { + return Math.round(Cast.toNumber(args.NUM)); + } + + mathop (args) { + const operator = Cast.toString(args.OPERATOR).toLowerCase(); + const n = Cast.toNumber(args.NUM); + switch (operator) { + case 'abs': return Math.abs(n); + case 'floor': return Math.floor(n); + case 'ceiling': return Math.ceil(n); + case 'sqrt': return Math.sqrt(n); + case 'sin': return parseFloat(Math.sin((Math.PI * n) / 180).toFixed(10)); + case 'cos': return parseFloat(Math.cos((Math.PI * n) / 180).toFixed(10)); + case 'tan': return MathUtil.tan(n); + case 'asin': return (Math.asin(n) * 180) / Math.PI; + case 'acos': return (Math.acos(n) * 180) / Math.PI; + case 'atan': return (Math.atan(n) * 180) / Math.PI; + case 'ln': return Math.log(n); + case 'log': return Math.log(n) / Math.LN10; + case 'e ^': return Math.exp(n); + case '10 ^': return Math.pow(10, n); + } + return 0; + } } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3OperatorsBlocks.prototype.getPrimitives = function() { - return { - 'operator_add': this.add, - 'operator_subtract': this.subtract, - 'operator_multiply': this.multiply, - 'operator_divide': this.divide, - 'operator_lt': this.lt, - 'operator_equals': this.equals, - 'operator_gt': this.gt, - 'operator_and': this.and, - 'operator_or': this.or, - 'operator_not': this.not, - 'operator_random': this.random, - 'operator_join': this.join, - 'operator_letter_of': this.letterOf, - 'operator_length': this.length, - 'operator_mod': this.mod, - 'operator_round': this.round, - 'operator_mathop': this.mathop - }; -}; - -Scratch3OperatorsBlocks.prototype.add = function (args) { - return Cast.toNumber(args.NUM1) + Cast.toNumber(args.NUM2); -}; - -Scratch3OperatorsBlocks.prototype.subtract = function (args) { - return Cast.toNumber(args.NUM1) - Cast.toNumber(args.NUM2); -}; - -Scratch3OperatorsBlocks.prototype.multiply = function (args) { - return Cast.toNumber(args.NUM1) * Cast.toNumber(args.NUM2); -}; - -Scratch3OperatorsBlocks.prototype.divide = function (args) { - return Cast.toNumber(args.NUM1) / Cast.toNumber(args.NUM2); -}; - -Scratch3OperatorsBlocks.prototype.lt = function (args) { - return Cast.compare(args.OPERAND1, args.OPERAND2) < 0; -}; - -Scratch3OperatorsBlocks.prototype.equals = function (args) { - return Cast.compare(args.OPERAND1, args.OPERAND2) == 0; -}; - -Scratch3OperatorsBlocks.prototype.gt = function (args) { - return Cast.compare(args.OPERAND1, args.OPERAND2) > 0; -}; - -Scratch3OperatorsBlocks.prototype.and = function (args) { - return Cast.toBoolean(args.OPERAND1) && Cast.toBoolean(args.OPERAND2); -}; - -Scratch3OperatorsBlocks.prototype.or = function (args) { - return Cast.toBoolean(args.OPERAND1) || Cast.toBoolean(args.OPERAND2); -}; - -Scratch3OperatorsBlocks.prototype.not = function (args) { - return !Cast.toBoolean(args.OPERAND); -}; - -Scratch3OperatorsBlocks.prototype.random = function (args) { - var nFrom = Cast.toNumber(args.FROM); - var nTo = Cast.toNumber(args.TO); - var low = nFrom <= nTo ? nFrom : nTo; - var high = nFrom <= nTo ? nTo : nFrom; - if (low == high) return low; - // If both arguments are ints, truncate the result to an int. - if (Cast.isInt(args.FROM) && Cast.isInt(args.TO)) { - return low + parseInt(Math.random() * ((high + 1) - low)); - } - return (Math.random() * (high - low)) + low; -}; - -Scratch3OperatorsBlocks.prototype.join = function (args) { - return Cast.toString(args.STRING1) + Cast.toString(args.STRING2); -}; - -Scratch3OperatorsBlocks.prototype.letterOf = function (args) { - var index = Cast.toNumber(args.LETTER) - 1; - var str = Cast.toString(args.STRING); - // Out of bounds? - if (index < 0 || index >= str.length) { - return ''; - } - return str.charAt(index); -}; - -Scratch3OperatorsBlocks.prototype.length = function (args) { - return Cast.toString(args.STRING).length; -}; - -Scratch3OperatorsBlocks.prototype.mod = function (args) { - var n = Cast.toNumber(args.NUM1); - var modulus = Cast.toNumber(args.NUM2); - var result = n % modulus; - // Scratch mod is kept positive. - if (result / modulus < 0) result += modulus; - return result; -}; - -Scratch3OperatorsBlocks.prototype.round = function (args) { - return Math.round(Cast.toNumber(args.NUM)); -}; - -Scratch3OperatorsBlocks.prototype.mathop = function (args) { - var operator = Cast.toString(args.OPERATOR).toLowerCase(); - var n = Cast.toNumber(args.NUM); - switch (operator) { - case 'abs': return Math.abs(n); - case 'floor': return Math.floor(n); - case 'ceiling': return Math.ceil(n); - case 'sqrt': return Math.sqrt(n); - case 'sin': return Math.sin((Math.PI * n) / 180); - case 'cos': return Math.cos((Math.PI * n) / 180); - case 'tan': return Math.tan((Math.PI * n) / 180); - case 'asin': return (Math.asin(n) * 180) / Math.PI; - case 'acos': return (Math.acos(n) * 180) / Math.PI; - case 'atan': return (Math.atan(n) * 180) / Math.PI; - case 'ln': return Math.log(n); - case 'log': return Math.log(n) / Math.LN10; - case 'e ^': return Math.exp(n); - case '10 ^': return Math.pow(10, n); - } - return 0; -}; - module.exports = Scratch3OperatorsBlocks; diff --git a/src/blocks/scratch3_procedures.js b/src/blocks/scratch3_procedures.js index 19a6df37..7fb31d28 100644 --- a/src/blocks/scratch3_procedures.js +++ b/src/blocks/scratch3_procedures.js @@ -1,44 +1,69 @@ -function Scratch3ProcedureBlocks(runtime) { +class Scratch3ProcedureBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } + /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; + getPrimitives () { + return { + procedures_definition: this.definition, + procedures_call: this.call, + argument_reporter_string_number: this.argumentReporterStringNumber, + argument_reporter_boolean: this.argumentReporterBoolean + }; + } + + definition () { + // No-op: execute the blocks. + } + + call (args, util) { + if (!util.stackFrame.executed) { + const procedureCode = args.mutation.proccode; + const paramNamesAndIds = util.getProcedureParamNamesAndIds(procedureCode); + + // If null, procedure could not be found, which can happen if custom + // block is dragged between sprites without the definition. + // Match Scratch 2.0 behavior and noop. + if (paramNamesAndIds === null) { + return; + } + + const [paramNames, paramIds] = paramNamesAndIds; + + for (let i = 0; i < paramIds.length; i++) { + if (args.hasOwnProperty(paramIds[i])) { + util.pushParam(paramNames[i], args[paramIds[i]]); + } + } + + util.stackFrame.executed = true; + util.startProcedure(procedureCode); + } + } + + argumentReporterStringNumber (args, util) { + const value = util.getParam(args.VALUE); + if (value === null) { + return ''; + } + return value; + } + + argumentReporterBoolean (args, util) { + const value = util.getParam(args.VALUE); + if (value === null) { + return false; + } + return value; + } } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3ProcedureBlocks.prototype.getPrimitives = function() { - return { - 'procedures_defnoreturn': this.defNoReturn, - 'procedures_callnoreturn': this.callNoReturn, - 'procedures_param': this.param - }; -}; - -Scratch3ProcedureBlocks.prototype.defNoReturn = function () { - // No-op: execute the blocks. -}; - -Scratch3ProcedureBlocks.prototype.callNoReturn = function (args, util) { - if (!util.stackFrame.executed) { - var procedureCode = args.mutation.proccode; - var paramNames = util.getProcedureParamNames(procedureCode); - for (var i = 0; i < paramNames.length; i++) { - if (args.hasOwnProperty('input' + i)) { - util.pushParam(paramNames[i], args['input' + i]); - } - } - util.stackFrame.executed = true; - util.startProcedure(procedureCode); - } -}; - -Scratch3ProcedureBlocks.prototype.param = function (args, util) { - var value = util.getParam(args.mutation.paramname); - return value; -}; - module.exports = Scratch3ProcedureBlocks; diff --git a/src/blocks/scratch3_sensing.js b/src/blocks/scratch3_sensing.js index 29327393..f7a39b6b 100644 --- a/src/blocks/scratch3_sensing.js +++ b/src/blocks/scratch3_sensing.js @@ -1,128 +1,269 @@ -var Cast = require('../util/cast'); +const Cast = require('../util/cast'); + +class Scratch3SensingBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + /** + * The "answer" block value. + * @type {string} + */ + this._answer = ''; + + /** + * The list of queued questions and respective `resolve` callbacks. + * @type {!Array} + */ + this._questionList = []; + + this.runtime.on('ANSWER', this._onAnswer.bind(this)); + this.runtime.on('PROJECT_START', this._resetAnswer.bind(this)); + this.runtime.on('PROJECT_STOP_ALL', this._clearAllQuestions.bind(this)); + } -function Scratch3SensingBlocks(runtime) { /** - * The runtime instantiating this block package. - * @type {Runtime} + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. */ - this.runtime = runtime; + getPrimitives () { + return { + sensing_touchingobject: this.touchingObject, + sensing_touchingcolor: this.touchingColor, + sensing_coloristouchingcolor: this.colorTouchingColor, + sensing_distanceto: this.distanceTo, + sensing_timer: this.getTimer, + sensing_resettimer: this.resetTimer, + sensing_of: this.getAttributeOf, + sensing_mousex: this.getMouseX, + sensing_mousey: this.getMouseY, + sensing_setdragmode: this.setDragMode, + sensing_mousedown: this.getMouseDown, + sensing_keypressed: this.getKeyPressed, + sensing_current: this.current, + sensing_dayssince2000: this.daysSince2000, + sensing_loudness: this.getLoudness, + sensing_askandwait: this.askAndWait, + sensing_answer: this.getAnswer + }; + } + + getMonitored () { + return { + sensing_answer: {}, + sensing_loudness: {}, + sensing_timer: {}, + sensing_current: {} + }; + } + + _onAnswer (answer) { + this._answer = answer; + const questionObj = this._questionList.shift(); + if (questionObj) { + const [_question, resolve, target, wasVisible, wasStage] = questionObj; + // If the target was visible when asked, hide the say bubble unless the target was the stage. + if (wasVisible && !wasStage) { + this.runtime.emit('SAY', target, 'say', ''); + } + resolve(); + this._askNextQuestion(); + } + } + + _resetAnswer () { + this._answer = ''; + } + + _enqueueAsk (question, resolve, target, wasVisible, wasStage) { + this._questionList.push([question, resolve, target, wasVisible, wasStage]); + } + + _askNextQuestion () { + if (this._questionList.length > 0) { + const [question, _resolve, target, wasVisible, wasStage] = this._questionList[0]; + // If the target is visible, emit a blank question and use the + // say event to trigger a bubble unless the target was the stage. + if (wasVisible && !wasStage) { + this.runtime.emit('SAY', target, 'say', question); + this.runtime.emit('QUESTION', ''); + } else { + this.runtime.emit('QUESTION', question); + } + } + } + + _clearAllQuestions () { + this._questionList = []; + this.runtime.emit('QUESTION', null); + } + + askAndWait (args, util) { + const _target = util.target; + return new Promise(resolve => { + const isQuestionAsked = this._questionList.length > 0; + this._enqueueAsk(String(args.QUESTION), resolve, _target, _target.visible, _target.isStage); + if (!isQuestionAsked) { + this._askNextQuestion(); + } + }); + } + + getAnswer () { + return this._answer; + } + + touchingObject (args, util) { + const requestedObject = args.TOUCHINGOBJECTMENU; + if (requestedObject === '_mouse_') { + const mouseX = util.ioQuery('mouse', 'getClientX'); + const mouseY = util.ioQuery('mouse', 'getClientY'); + return util.target.isTouchingPoint(mouseX, mouseY); + } else if (requestedObject === '_edge_') { + return util.target.isTouchingEdge(); + } + return util.target.isTouchingSprite(requestedObject); + + } + + touchingColor (args, util) { + const color = Cast.toRgbColorList(args.COLOR); + return util.target.isTouchingColor(color); + } + + colorTouchingColor (args, util) { + const maskColor = Cast.toRgbColorList(args.COLOR); + const targetColor = Cast.toRgbColorList(args.COLOR2); + return util.target.colorIsTouchingColor(targetColor, maskColor); + } + + distanceTo (args, util) { + if (util.target.isStage) return 10000; + + let targetX = 0; + let targetY = 0; + if (args.DISTANCETOMENU === '_mouse_') { + targetX = util.ioQuery('mouse', 'getScratchX'); + targetY = util.ioQuery('mouse', 'getScratchY'); + } else { + const distTarget = this.runtime.getSpriteTargetByName( + args.DISTANCETOMENU + ); + if (!distTarget) return 10000; + targetX = distTarget.x; + targetY = distTarget.y; + } + + const dx = util.target.x - targetX; + const dy = util.target.y - targetY; + return Math.sqrt((dx * dx) + (dy * dy)); + } + + setDragMode (args, util) { + util.target.setDraggable(args.DRAG_MODE === 'draggable'); + } + + getTimer (args, util) { + return util.ioQuery('clock', 'projectTimer'); + } + + resetTimer (args, util) { + util.ioQuery('clock', 'resetProjectTimer'); + } + + getMouseX (args, util) { + return util.ioQuery('mouse', 'getScratchX'); + } + + getMouseY (args, util) { + return util.ioQuery('mouse', 'getScratchY'); + } + + getMouseDown (args, util) { + return util.ioQuery('mouse', 'getIsDown'); + } + + current (args) { + const menuOption = Cast.toString(args.CURRENTMENU).toLowerCase(); + const date = new Date(); + switch (menuOption) { + case 'year': return date.getFullYear(); + case 'month': return date.getMonth() + 1; // getMonth is zero-based + case 'date': return date.getDate(); + case 'dayofweek': return date.getDay() + 1; // getDay is zero-based, Sun=0 + case 'hour': return date.getHours(); + case 'minute': return date.getMinutes(); + case 'second': return date.getSeconds(); + } + return 0; + } + + getKeyPressed (args, util) { + return util.ioQuery('keyboard', 'getKeyIsDown', [args.KEY_OPTION]); + } + + daysSince2000 () { + const msPerDay = 24 * 60 * 60 * 1000; + const start = new Date(2000, 0, 1); // Months are 0-indexed. + const today = new Date(); + const dstAdjust = today.getTimezoneOffset() - start.getTimezoneOffset(); + let mSecsSinceStart = today.valueOf() - start.valueOf(); + mSecsSinceStart += ((today.getTimezoneOffset() - dstAdjust) * 60 * 1000); + return mSecsSinceStart / msPerDay; + } + + getLoudness () { + if (typeof this.runtime.audioEngine === 'undefined') return -1; + return this.runtime.audioEngine.getLoudness(); + } + + getAttributeOf (args) { + let attrTarget; + + if (args.OBJECT === '_stage_') { + attrTarget = this.runtime.getTargetForStage(); + } else { + attrTarget = this.runtime.getSpriteTargetByName(args.OBJECT); + } + + // Generic attributes + if (attrTarget.isStage) { + switch (args.PROPERTY) { + // Scratch 1.4 support + case 'background #': return attrTarget.currentCostume + 1; + + case 'backdrop #': return attrTarget.currentCostume + 1; + case 'backdrop name': + return attrTarget.sprite.costumes[attrTarget.currentCostume].name; + case 'volume': return; // @todo: Keep this in mind for sound blocks! + } + } else { + switch (args.PROPERTY) { + case 'x position': return attrTarget.x; + case 'y position': return attrTarget.y; + case 'direction': return attrTarget.direction; + case 'costume #': return attrTarget.currentCostume + 1; + case 'costume name': + return attrTarget.sprite.costumes[attrTarget.currentCostume].name; + case 'size': return attrTarget.size; + case 'volume': return; // @todo: above, keep in mind for sound blocks.. + } + } + + // Variables + const varName = args.PROPERTY; + for (const id in attrTarget.variables) { + if (attrTarget.variables[id].name === varName) { + return attrTarget.variables[id].value; + } + } + + // Otherwise, 0 + return 0; + } } -/** - * Retrieve the block primitives implemented by this package. - * @return {Object.} Mapping of opcode to Function. - */ -Scratch3SensingBlocks.prototype.getPrimitives = function() { - return { - 'sensing_touchingobject': this.touchingObject, - 'sensing_touchingcolor': this.touchingColor, - 'sensing_coloristouchingcolor': this.colorTouchingColor, - 'sensing_distanceto': this.distanceTo, - 'sensing_timer': this.getTimer, - 'sensing_resettimer': this.resetTimer, - 'sensing_mousex': this.getMouseX, - 'sensing_mousey': this.getMouseY, - 'sensing_mousedown': this.getMouseDown, - 'sensing_keypressed': this.getKeyPressed, - 'sensing_current': this.current, - 'sensing_dayssince2000': this.daysSince2000 - }; -}; - -Scratch3SensingBlocks.prototype.touchingObject = function (args, util) { - var requestedObject = args.TOUCHINGOBJECTMENU; - if (requestedObject == '_mouse_') { - var mouseX = util.ioQuery('mouse', 'getX'); - var mouseY = util.ioQuery('mouse', 'getY'); - return util.target.isTouchingPoint(mouseX, mouseY); - } else if (requestedObject == '_edge_') { - return util.target.isTouchingEdge(); - } else { - return util.target.isTouchingSprite(requestedObject); - } -}; - -Scratch3SensingBlocks.prototype.touchingColor = function (args, util) { - var color = Cast.toRgbColorList(args.COLOR); - return util.target.isTouchingColor(color); -}; - -Scratch3SensingBlocks.prototype.colorTouchingColor = function (args, util) { - var maskColor = Cast.toRgbColorList(args.COLOR); - var targetColor = Cast.toRgbColorList(args.COLOR2); - return util.target.colorIsTouchingColor(targetColor, maskColor); -}; - -Scratch3SensingBlocks.prototype.distanceTo = function (args, util) { - if (util.target.isStage) return 10000; - - var targetX = 0; - var targetY = 0; - if (args.DISTANCETOMENU === '_mouse_') { - targetX = util.ioQuery('mouse', 'getX'); - targetY = util.ioQuery('mouse', 'getY'); - } else { - var distTarget = this.runtime.getSpriteTargetByName( - args.DISTANCETOMENU - ); - if (!distTarget) return 10000; - targetX = distTarget.x; - targetY = distTarget.y; - } - - var dx = util.target.x - targetX; - var dy = util.target.y - targetY; - return Math.sqrt((dx * dx) + (dy * dy)); -}; - -Scratch3SensingBlocks.prototype.getTimer = function (args, util) { - return util.ioQuery('clock', 'projectTimer'); -}; - -Scratch3SensingBlocks.prototype.resetTimer = function (args, util) { - util.ioQuery('clock', 'resetProjectTimer'); -}; - -Scratch3SensingBlocks.prototype.getMouseX = function (args, util) { - return util.ioQuery('mouse', 'getX'); -}; - -Scratch3SensingBlocks.prototype.getMouseY = function (args, util) { - return util.ioQuery('mouse', 'getY'); -}; - -Scratch3SensingBlocks.prototype.getMouseDown = function (args, util) { - return util.ioQuery('mouse', 'getIsDown'); -}; - -Scratch3SensingBlocks.prototype.current = function (args) { - var menuOption = Cast.toString(args.CURRENTMENU).toLowerCase(); - var date = new Date(); - switch (menuOption) { - case 'year': return date.getFullYear(); - case 'month': return date.getMonth() + 1; // getMonth is zero-based - case 'date': return date.getDate(); - case 'dayofweek': return date.getDay() + 1; // getDay is zero-based, Sun=0 - case 'hour': return date.getHours(); - case 'minute': return date.getMinutes(); - case 'second': return date.getSeconds(); - } - return 0; -}; - -Scratch3SensingBlocks.prototype.getKeyPressed = function (args, util) { - return util.ioQuery('keyboard', 'getKeyIsDown', args.KEY_OPTION); -}; - -Scratch3SensingBlocks.prototype.daysSince2000 = function() -{ - var msPerDay = 24 * 60 * 60 * 1000; - var start = new Date(2000, 1-1, 1); - var today = new Date(); - var dstAdjust = today.getTimezoneOffset() - start.getTimezoneOffset(); - var mSecsSinceStart = today.valueOf() - start.valueOf(); - mSecsSinceStart += ((today.getTimezoneOffset() - dstAdjust) * 60 * 1000); - return mSecsSinceStart / msPerDay; -}; - module.exports = Scratch3SensingBlocks; diff --git a/src/blocks/scratch3_sound.js b/src/blocks/scratch3_sound.js new file mode 100644 index 00000000..f79575b2 --- /dev/null +++ b/src/blocks/scratch3_sound.js @@ -0,0 +1,301 @@ +const MathUtil = require('../util/math-util'); +const Cast = require('../util/cast'); +const Clone = require('../util/clone'); + +class Scratch3SoundBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + // Clear sound effects on green flag and stop button events. + this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this); + if (this.runtime) { + this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets); + this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets); + } + + this._onTargetCreated = this._onTargetCreated.bind(this); + if (this.runtime) { + runtime.on('targetWasCreated', this._onTargetCreated); + } + } + + /** + * The key to load & store a target's sound-related state. + * @type {string} + */ + static get STATE_KEY () { + return 'Scratch.sound'; + } + + /** + * The default sound-related state, to be used when a target has no existing sound state. + * @type {SoundState} + */ + static get DEFAULT_SOUND_STATE () { + return { + volume: 100, + effects: { + pitch: 0, + pan: 0 + } + }; + } + + /** + * The minimum and maximum MIDI note numbers, for clamping the input to play note. + * @type {{min: number, max: number}} + */ + static get MIDI_NOTE_RANGE () { + return {min: 36, max: 96}; // C2 to C7 + } + + /** + * The minimum and maximum beat values, for clamping the duration of play note, play drum and rest. + * 100 beats at the default tempo of 60bpm is 100 seconds. + * @type {{min: number, max: number}} + */ + static get BEAT_RANGE () { + return {min: 0, max: 100}; + } + + /** The minimum and maximum tempo values, in bpm. + * @type {{min: number, max: number}} + */ + static get TEMPO_RANGE () { + return {min: 20, max: 500}; + } + + /** The minimum and maximum values for each sound effect. + * @type {{effect:{min: number, max: number}}} + */ + static get EFFECT_RANGE () { + return { + pitch: {min: -600, max: 600}, // -5 to 5 octaves + pan: {min: -100, max: 100} // 100% left to 100% right + }; + } + + /** + * @param {Target} target - collect sound state for this target. + * @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary. + * @private + */ + _getSoundState (target) { + let soundState = target.getCustomState(Scratch3SoundBlocks.STATE_KEY); + if (!soundState) { + soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE); + target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState); + } + return soundState; + } + + /** + * When a Target is cloned, clone the sound state. + * @param {Target} newTarget - the newly created target. + * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. + * @listens Runtime#event:targetWasCreated + * @private + */ + _onTargetCreated (newTarget, sourceTarget) { + if (sourceTarget) { + const soundState = sourceTarget.getCustomState(Scratch3SoundBlocks.STATE_KEY); + if (soundState && newTarget) { + newTarget.setCustomState(Scratch3SoundBlocks.STATE_KEY, Clone.simple(soundState)); + this._syncEffectsForTarget(newTarget); + } + } + } + + /** + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. + */ + getPrimitives () { + return { + sound_play: this.playSound, + sound_playuntildone: this.playSoundAndWait, + sound_stopallsounds: this.stopAllSounds, + sound_seteffectto: this.setEffect, + sound_changeeffectby: this.changeEffect, + sound_cleareffects: this.clearEffects, + sound_sounds_menu: this.soundsMenu, + sound_beats_menu: this.beatsMenu, + sound_effects_menu: this.effectsMenu, + sound_setvolumeto: this.setVolume, + sound_changevolumeby: this.changeVolume, + sound_volume: this.getVolume + }; + } + + getMonitored () { + return { + sound_volume: {} + }; + } + + playSound (args, util) { + const index = this._getSoundIndex(args.SOUND_MENU, util); + if (index >= 0) { + const soundId = util.target.sprite.sounds[index].soundId; + if (util.target.audioPlayer === null) return; + util.target.audioPlayer.playSound(soundId); + } + } + + playSoundAndWait (args, util) { + const index = this._getSoundIndex(args.SOUND_MENU, util); + if (index >= 0) { + const soundId = util.target.sprite.sounds[index].soundId; + if (util.target.audioPlayer === null) return; + return util.target.audioPlayer.playSound(soundId); + } + } + + _getSoundIndex (soundName, util) { + // if the sprite has no sounds, return -1 + const len = util.target.sprite.sounds.length; + if (len === 0) { + return -1; + } + + // look up by name first + const index = this.getSoundIndexByName(soundName, util); + if (index !== -1) { + return index; + } + + // then try using the sound name as a 1-indexed index + const oneIndexedIndex = parseInt(soundName, 10); + if (!isNaN(oneIndexedIndex)) { + return MathUtil.wrapClamp(oneIndexedIndex - 1, 0, len - 1); + } + + // could not be found as a name or converted to index, return -1 + return -1; + } + + getSoundIndexByName (soundName, util) { + const sounds = util.target.sprite.sounds; + for (let i = 0; i < sounds.length; i++) { + if (sounds[i].name === soundName) { + return i; + } + } + // if there is no sound by that name, return -1 + return -1; + } + + stopAllSounds () { + if (this.runtime.targets === null) return; + const allTargets = this.runtime.targets; + for (let i = 0; i < allTargets.length; i++) { + this._stopAllSoundsForTarget(allTargets[i]); + } + } + + _stopAllSoundsForTarget (target) { + if (target.audioPlayer === null) return; + target.audioPlayer.stopAllSounds(); + } + + setEffect (args, util) { + this._updateEffect(args, util, false); + } + + changeEffect (args, util) { + this._updateEffect(args, util, true); + } + + _updateEffect (args, util, change) { + const effect = Cast.toString(args.EFFECT).toLowerCase(); + const value = Cast.toNumber(args.VALUE); + + const soundState = this._getSoundState(util.target); + if (!soundState.effects.hasOwnProperty(effect)) return; + + if (change) { + soundState.effects[effect] += value; + } else { + soundState.effects[effect] = value; + } + + const effectRange = Scratch3SoundBlocks.EFFECT_RANGE[effect]; + soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], effectRange.min, effectRange.max); + + if (util.target.audioPlayer === null) return; + util.target.audioPlayer.setEffect(effect, soundState.effects[effect]); + } + + _syncEffectsForTarget (target) { + if (!target || !target.audioPlayer) return; + const soundState = this._getSoundState(target); + for (const effect in soundState.effects) { + if (!soundState.effects.hasOwnProperty(effect)) continue; + target.audioPlayer.setEffect(effect, soundState.effects[effect]); + } + } + + clearEffects (args, util) { + this._clearEffectsForTarget(util.target); + } + + _clearEffectsForTarget (target) { + const soundState = this._getSoundState(target); + for (const effect in soundState.effects) { + if (!soundState.effects.hasOwnProperty(effect)) continue; + soundState.effects[effect] = 0; + } + if (target.audioPlayer === null) return; + target.audioPlayer.clearEffects(); + } + + _clearEffectsForAllTargets () { + if (this.runtime.targets === null) return; + const allTargets = this.runtime.targets; + for (let i = 0; i < allTargets.length; i++) { + this._clearEffectsForTarget(allTargets[i]); + } + } + + setVolume (args, util) { + const volume = Cast.toNumber(args.VOLUME); + this._updateVolume(volume, util); + } + + changeVolume (args, util) { + const soundState = this._getSoundState(util.target); + const volume = Cast.toNumber(args.VOLUME) + soundState.volume; + this._updateVolume(volume, util); + } + + _updateVolume (volume, util) { + const soundState = this._getSoundState(util.target); + volume = MathUtil.clamp(volume, 0, 100); + soundState.volume = volume; + if (util.target.audioPlayer === null) return; + util.target.audioPlayer.setVolume(soundState.volume); + } + + getVolume (args, util) { + const soundState = this._getSoundState(util.target); + return soundState.volume; + } + + soundsMenu (args) { + return args.SOUND_MENU; + } + + beatsMenu (args) { + return args.BEATS; + } + + effectsMenu (args) { + return args.EFFECT; + } +} + +module.exports = Scratch3SoundBlocks; diff --git a/src/dispatch/central-dispatch.js b/src/dispatch/central-dispatch.js new file mode 100644 index 00000000..6dd23625 --- /dev/null +++ b/src/dispatch/central-dispatch.js @@ -0,0 +1,111 @@ +const SharedDispatch = require('./shared-dispatch'); + +const log = require('../util/log'); + +/** + * This class serves as the central broker for message dispatch. It expects to operate on the main thread / Window and + * it must be informed of any Worker threads which will participate in the messaging system. From any context in the + * messaging system, the dispatcher's "call" method can call any method on any "service" provided in any participating + * context. The dispatch system will forward function arguments and return values across worker boundaries as needed. + * @see {WorkerDispatch} + */ +class CentralDispatch extends SharedDispatch { + constructor () { + super(); + + /** + * Map of channel name to worker or local service provider. + * If the entry is a Worker, the service is provided by an object on that worker. + * Otherwise, the service is provided locally and methods on the service will be called directly. + * @see {setService} + * @type {object.} + */ + this.services = {}; + + /** + * The constructor we will use to recognize workers. + * @type {Function} + */ + this.workerClass = (typeof Worker === 'undefined' ? null : Worker); + + /** + * List of workers attached to this dispatcher. + * @type {Array} + */ + this.workers = []; + } + + /** + * Set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param {object} provider - a local object which provides this service. + * @returns {Promise} - a promise which will resolve once the service is registered. + */ + setService (service, provider) { + /** Return a promise for consistency with {@link WorkerDispatch#setService} */ + try { + if (this.services.hasOwnProperty(service)) { + log.warn(`Central dispatch replacing existing service provider for ${service}`); + } + this.services[service] = provider; + return Promise.resolve(); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Add a worker to the message dispatch system. The worker must implement a compatible message dispatch framework. + * The dispatcher will immediately attempt to "handshake" with the worker. + * @param {Worker} worker - the worker to add into the dispatch system. + */ + addWorker (worker) { + if (this.workers.indexOf(worker) === -1) { + this.workers.push(worker); + worker.onmessage = this._onMessage.bind(this, worker); + this._remoteCall(worker, 'dispatch', 'handshake').catch(e => { + log.error(`Could not handshake with worker: ${JSON.stringify(e)}`); + }); + } else { + log.warn('Central dispatch ignoring attempt to add duplicate worker'); + } + } + + /** + * Fetch the service provider object for a particular service name. + * @override + * @param {string} service - the name of the service to look up + * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found + * @protected + */ + _getServiceProvider (service) { + const provider = this.services[service]; + return provider && { + provider, + isRemote: provider instanceof this.workerClass + }; + } + + /** + * Handle a call message sent to the dispatch service itself + * @override + * @param {Worker} worker - the worker which sent the message. + * @param {DispatchCallMessage} message - the message to be handled. + * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate + * @protected + */ + _onDispatchMessage (worker, message) { + let promise; + switch (message.method) { + case 'setService': + promise = this.setService(message.args[0], worker); + break; + default: + log.error(`Central dispatch received message for unknown method: ${message.method}`); + } + return promise; + } +} + +module.exports = new CentralDispatch(); diff --git a/src/dispatch/shared-dispatch.js b/src/dispatch/shared-dispatch.js new file mode 100644 index 00000000..1e4785bc --- /dev/null +++ b/src/dispatch/shared-dispatch.js @@ -0,0 +1,233 @@ +const log = require('../util/log'); + +/** + * @typedef {object} DispatchCallMessage - a message to the dispatch system representing a service method call + * @property {*} responseId - send a response message with this response ID. See {@link DispatchResponseMessage} + * @property {string} service - the name of the service to be called + * @property {string} method - the name of the method to be called + * @property {Array|undefined} args - the arguments to be passed to the method + */ + +/** + * @typedef {object} DispatchResponseMessage - a message to the dispatch system representing the results of a call + * @property {*} responseId - a copy of the response ID from the call which generated this response + * @property {*|undefined} error - if this is truthy, then it contains results from a failed call (such as an exception) + * @property {*|undefined} result - if error is not truthy, then this contains the return value of the call (if any) + */ + +/** + * @typedef {DispatchCallMessage|DispatchResponseMessage} DispatchMessage + * Any message to the dispatch system. + */ + +/** + * The SharedDispatch class is responsible for dispatch features shared by + * {@link CentralDispatch} and {@link WorkerDispatch}. + */ +class SharedDispatch { + constructor () { + /** + * List of callback registrations for promises waiting for a response from a call to a service on another + * worker. A callback registration is an array of [resolve,reject] Promise functions. + * Calls to local services don't enter this list. + * @type {Array.<[Function,Function]>} + */ + this.callbacks = []; + + /** + * The next response ID to be used. + * @type {int} + */ + this.nextResponseId = 0; + } + + /** + * Call a particular method on a particular service, regardless of whether that service is provided locally or on + * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone + * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be + * transferred to the worker, and they should not be used after this call. + * @example + * dispatcher.call('vm', 'setData', 'cat', 42); + * // this finds the worker for the 'vm' service, then on that worker calls: + * vm.setData('cat', 42); + * @param {string} service - the name of the service. + * @param {string} method - the name of the method. + * @param {*} [args] - the arguments to be copied to the method, if any. + * @returns {Promise} - a promise for the return value of the service method. + */ + call (service, method, ...args) { + return this.transferCall(service, method, null, ...args); + } + + /** + * Call a particular method on a particular service, regardless of whether that service is provided locally or on + * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone + * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be + * transferred to the worker, and they should not be used after this call. + * @example + * dispatcher.transferCall('vm', 'setData', [myArrayBuffer], 'cat', myArrayBuffer); + * // this finds the worker for the 'vm' service, transfers `myArrayBuffer` to it, then on that worker calls: + * vm.setData('cat', myArrayBuffer); + * @param {string} service - the name of the service. + * @param {string} method - the name of the method. + * @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful. + * @param {*} [args] - the arguments to be copied to the method, if any. + * @returns {Promise} - a promise for the return value of the service method. + */ + transferCall (service, method, transfer, ...args) { + try { + const {provider, isRemote} = this._getServiceProvider(service); + if (provider) { + if (isRemote) { + return this._remoteTransferCall(provider, service, method, transfer, ...args); + } + + const result = provider[method].apply(provider, args); + return Promise.resolve(result); + } + return Promise.reject(new Error(`Service not found: ${service}`)); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Check if a particular service lives on another worker. + * @param {string} service - the service to check. + * @returns {boolean} - true if the service is remote (calls must cross a Worker boundary), false otherwise. + * @private + */ + _isRemoteService (service) { + return this._getServiceProvider(service).isRemote; + } + + /** + * Like {@link call}, but force the call to be posted through a particular communication channel. + * @param {object} provider - send the call through this object's `postMessage` function. + * @param {string} service - the name of the service. + * @param {string} method - the name of the method. + * @param {*} [args] - the arguments to be copied to the method, if any. + * @returns {Promise} - a promise for the return value of the service method. + */ + _remoteCall (provider, service, method, ...args) { + return this._remoteTransferCall(provider, service, method, null, ...args); + } + + /** + * Like {@link transferCall}, but force the call to be posted through a particular communication channel. + * @param {object} provider - send the call through this object's `postMessage` function. + * @param {string} service - the name of the service. + * @param {string} method - the name of the method. + * @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful. + * @param {*} [args] - the arguments to be copied to the method, if any. + * @returns {Promise} - a promise for the return value of the service method. + */ + _remoteTransferCall (provider, service, method, transfer, ...args) { + return new Promise((resolve, reject) => { + const responseId = this._storeCallbacks(resolve, reject); + + /** @TODO: remove this hack! this is just here so we don't try to send `util` to a worker */ + if ((args.length > 0) && (typeof args[args.length - 1].yield === 'function')) { + args.pop(); + } + + if (transfer) { + provider.postMessage({service, method, responseId, args}, transfer); + } else { + provider.postMessage({service, method, responseId, args}); + } + }); + } + + /** + * Store callback functions pending a response message. + * @param {Function} resolve - function to call if the service method returns. + * @param {Function} reject - function to call if the service method throws. + * @returns {*} - a unique response ID for this set of callbacks. See {@link _deliverResponse}. + * @protected + */ + _storeCallbacks (resolve, reject) { + const responseId = this.nextResponseId++; + this.callbacks[responseId] = [resolve, reject]; + return responseId; + } + + /** + * Deliver call response from a worker. This should only be called as the result of a message from a worker. + * @param {int} responseId - the response ID of the callback set to call. + * @param {DispatchResponseMessage} message - the message containing the response value(s). + * @protected + */ + _deliverResponse (responseId, message) { + try { + const [resolve, reject] = this.callbacks[responseId]; + delete this.callbacks[responseId]; + if (message.error) { + reject(message.error); + } else { + resolve(message.result); + } + } catch (e) { + log.error(`Dispatch callback failed: ${JSON.stringify(e)}`); + } + } + + /** + * Handle a message event received from a connected worker. + * @param {Worker} worker - the worker which sent the message, or the global object if running in a worker. + * @param {MessageEvent} event - the message event to be handled. + * @protected + */ + _onMessage (worker, event) { + /** @type {DispatchMessage} */ + const message = event.data; + message.args = message.args || []; + let promise; + if (message.service) { + if (message.service === 'dispatch') { + promise = this._onDispatchMessage(worker, message); + } else { + promise = this.call(message.service, message.method, ...message.args); + } + } else if (typeof message.responseId === 'undefined') { + log.error(`Dispatch caught malformed message from a worker: ${JSON.stringify(event)}`); + } else { + this._deliverResponse(message.responseId, message); + } + if (promise) { + if (typeof message.responseId === 'undefined') { + log.error(`Dispatch message missing required response ID: ${JSON.stringify(event)}`); + } else { + promise.then( + result => worker.postMessage({responseId: message.responseId, result}), + error => worker.postMessage({responseId: message.responseId, error}) + ); + } + } + } + + /** + * Fetch the service provider object for a particular service name. + * @abstract + * @param {string} service - the name of the service to look up + * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found + * @protected + */ + _getServiceProvider (service) { + throw new Error(`Could not get provider for ${service}: _getServiceProvider not implemented`); + } + + /** + * Handle a call message sent to the dispatch service itself + * @abstract + * @param {Worker} worker - the worker which sent the message. + * @param {DispatchCallMessage} message - the message to be handled. + * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate + * @private + */ + _onDispatchMessage (worker, message) { + throw new Error(`Unimplemented dispatch message handler cannot handle ${message.method} method`); + } +} + +module.exports = SharedDispatch; diff --git a/src/dispatch/worker-dispatch.js b/src/dispatch/worker-dispatch.js new file mode 100644 index 00000000..10542af4 --- /dev/null +++ b/src/dispatch/worker-dispatch.js @@ -0,0 +1,110 @@ +const SharedDispatch = require('./shared-dispatch'); + +const log = require('../util/log'); + +/** + * This class provides a Worker with the means to participate in the message dispatch system managed by CentralDispatch. + * From any context in the messaging system, the dispatcher's "call" method can call any method on any "service" + * provided in any participating context. The dispatch system will forward function arguments and return values across + * worker boundaries as needed. + * @see {CentralDispatch} + */ +class WorkerDispatch extends SharedDispatch { + constructor () { + super(); + + /** + * This promise will be resolved when we have successfully connected to central dispatch. + * @type {Promise} + * @see {waitForConnection} + * @private + */ + this._connectionPromise = new Promise(resolve => { + this._onConnect = resolve; + }); + + /** + * Map of service name to local service provider. + * If a service is not listed here, it is assumed to be provided by another context (another Worker or the main + * thread). + * @see {setService} + * @type {object} + */ + this.services = {}; + + this._onMessage = this._onMessage.bind(this, self); + if (typeof self !== 'undefined') { + self.onmessage = this._onMessage; + } + } + + /** + * @returns {Promise} a promise which will resolve upon connection to central dispatch. If you need to make a call + * immediately on "startup" you can attach a 'then' to this promise. + * @example + * dispatch.waitForConnection.then(() => { + * dispatch.call('myService', 'hello'); + * }) + */ + get waitForConnection () { + return this._connectionPromise; + } + + /** + * Set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param {object} provider - a local object which provides this service. + * @returns {Promise} - a promise which will resolve once the service is registered. + */ + setService (service, provider) { + if (this.services.hasOwnProperty(service)) { + log.warn(`Worker dispatch replacing existing service provider for ${service}`); + } + this.services[service] = provider; + return this.waitForConnection.then(() => this._remoteCall(self, 'dispatch', 'setService', service)); + } + + /** + * Fetch the service provider object for a particular service name. + * @override + * @param {string} service - the name of the service to look up + * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found + * @protected + */ + _getServiceProvider (service) { + // if we don't have a local service by this name, contact central dispatch by calling `postMessage` on self + const provider = this.services[service]; + return { + provider: provider || self, + isRemote: !provider + }; + } + + /** + * Handle a call message sent to the dispatch service itself + * @override + * @param {Worker} worker - the worker which sent the message. + * @param {DispatchCallMessage} message - the message to be handled. + * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate + * @protected + */ + _onDispatchMessage (worker, message) { + let promise; + switch (message.method) { + case 'handshake': + promise = this._onConnect(); + break; + case 'terminate': + // Don't close until next tick, after sending confirmation back + setTimeout(() => self.close(), 0); + promise = Promise.resolve(); + break; + default: + log.error(`Worker dispatch received message for unknown method: ${message.method}`); + } + return promise; + } +} + +module.exports = new WorkerDispatch(); diff --git a/src/engine/adapter.js b/src/engine/adapter.js index 327aae5e..7226c9b2 100644 --- a/src/engine/adapter.js +++ b/src/engine/adapter.js @@ -1,59 +1,18 @@ -var mutationAdapter = require('./mutation-adapter'); -var html = require('htmlparser2'); - -/** - * Adapter between block creation events and block representation which can be - * used by the Scratch runtime. - * @param {Object} e `Blockly.events.create` - * @return {Array.} List of blocks from this CREATE event. - */ -module.exports = function (e) { - // Validate input - if (typeof e !== 'object') return; - if (typeof e.xml !== 'object') return; - - return domToBlocks(html.parseDOM(e.xml.outerHTML)); -}; - -/** - * Convert outer blocks DOM from a Blockly CREATE event - * to a usable form for the Scratch runtime. - * This structure is based on Blockly xml.js:`domToWorkspace` and `domToBlock`. - * @param {Element} blocksDOM DOM tree for this event. - * @return {Array.} Usable list of blocks from this CREATE event. - */ -function domToBlocks (blocksDOM) { - // At this level, there could be multiple blocks adjacent in the DOM tree. - var blocks = {}; - for (var i = 0; i < blocksDOM.length; i++) { - var block = blocksDOM[i]; - if (!block.name || !block.attribs) { - continue; - } - var tagName = block.name.toLowerCase(); - if (tagName == 'block' || tagName == 'shadow') { - domToBlock(block, blocks, true, null); - } - } - // Flatten blocks object into a list. - var blocksList = []; - for (var b in blocks) { - blocksList.push(blocks[b]); - } - return blocksList; -} +const mutationAdapter = require('./mutation-adapter'); +const html = require('htmlparser2'); /** * Convert and an individual block DOM to the representation tree. * Based on Blockly's `domToBlockHeadless_`. * @param {Element} blockDOM DOM tree for an individual block. - * @param {Object} blocks Collection of blocks to add to. - * @param {Boolean} isTopBlock Whether blocks at this level are "top blocks." + * @param {object} blocks Collection of blocks to add to. + * @param {boolean} isTopBlock Whether blocks at this level are "top blocks." * @param {?string} parent Parent block ID. + * @return {undefined} */ -function domToBlock (blockDOM, blocks, isTopBlock, parent) { +const domToBlock = function (blockDOM, blocks, isTopBlock, parent) { // Block skeleton. - var block = { + const block = { id: blockDOM.attribs.id, // Block ID opcode: blockDOM.attribs.type, // For execution, "event_whengreenflag". inputs: {}, // Inputs to this block and the blocks they point to. @@ -61,7 +20,7 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) { next: null, // Next block in the stack, if one exists. topLevel: isTopBlock, // If this block starts a stack. parent: parent, // Parent block ID, if available. - shadow: blockDOM.name == 'shadow', // If this represents a shadow/slot. + shadow: blockDOM.name === 'shadow', // If this represents a shadow/slot. x: blockDOM.attribs.x, // X position of script, if top-level. y: blockDOM.attribs.y // Y position of script, if top-level. }; @@ -70,21 +29,21 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) { blocks[block.id] = block; // Process XML children and find enclosed blocks, fields, etc. - for (var i = 0; i < blockDOM.children.length; i++) { - var xmlChild = blockDOM.children[i]; + for (let i = 0; i < blockDOM.children.length; i++) { + const xmlChild = blockDOM.children[i]; // Enclosed blocks and shadows - var childBlockNode = null; - var childShadowNode = null; - for (var j = 0; j < xmlChild.children.length; j++) { - var grandChildNode = xmlChild.children[j]; + let childBlockNode = null; + let childShadowNode = null; + for (let j = 0; j < xmlChild.children.length; j++) { + const grandChildNode = xmlChild.children[j]; if (!grandChildNode.name) { // Non-XML tag node. continue; } - var grandChildNodeName = grandChildNode.name.toLowerCase(); - if (grandChildNodeName == 'block') { + const grandChildNodeName = grandChildNode.name.toLowerCase(); + if (grandChildNodeName === 'block') { childBlockNode = grandChildNode; - } else if (grandChildNodeName == 'shadow') { + } else if (grandChildNodeName === 'shadow') { childShadowNode = grandChildNode; } } @@ -98,9 +57,12 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) { // as we won't be using all of them for Scratch. switch (xmlChild.name.toLowerCase()) { case 'field': + { // Add the field to this block. - var fieldName = xmlChild.attribs.name; - var fieldData = ''; + const fieldName = xmlChild.attribs.name; + // Add id in case it is a variable field + const fieldId = xmlChild.attribs.id; + let fieldData = ''; if (xmlChild.children.length > 0 && xmlChild.children[0].data) { fieldData = xmlChild.children[0].data; } else { @@ -110,26 +72,35 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) { } block.fields[fieldName] = { name: fieldName, + id: fieldId, value: fieldData }; + const fieldVarType = xmlChild.attribs.variabletype; + if (typeof fieldVarType === 'string') { + block.fields[fieldName].variableType = fieldVarType; + } break; + } case 'value': case 'statement': + { // Recursively generate block structure for input block. domToBlock(childBlockNode, blocks, false, block.id); - if (childShadowNode && childBlockNode != childShadowNode) { + if (childShadowNode && childBlockNode !== childShadowNode) { // Also generate the shadow block. domToBlock(childShadowNode, blocks, false, block.id); } // Link this block's input to the child block. - var inputName = xmlChild.attribs.name; + const inputName = xmlChild.attribs.name; block.inputs[inputName] = { name: inputName, block: childBlockNode.attribs.id, shadow: childShadowNode ? childShadowNode.attribs.id : null }; break; + } case 'next': + { if (!childBlockNode || !childBlockNode.attribs) { // Invalid child block. continue; @@ -139,9 +110,57 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) { // Link next block to this block. block.next = childBlockNode.attribs.id; break; + } case 'mutation': + { block.mutation = mutationAdapter(xmlChild); break; } + } } -} +}; + +/** + * Convert outer blocks DOM from a Blockly CREATE event + * to a usable form for the Scratch runtime. + * This structure is based on Blockly xml.js:`domToWorkspace` and `domToBlock`. + * @param {Element} blocksDOM DOM tree for this event. + * @return {Array.} Usable list of blocks from this CREATE event. + */ +const domToBlocks = function (blocksDOM) { + // At this level, there could be multiple blocks adjacent in the DOM tree. + const blocks = {}; + for (let i = 0; i < blocksDOM.length; i++) { + const block = blocksDOM[i]; + if (!block.name || !block.attribs) { + continue; + } + const tagName = block.name.toLowerCase(); + if (tagName === 'block' || tagName === 'shadow') { + domToBlock(block, blocks, true, null); + } + } + // Flatten blocks object into a list. + const blocksList = []; + for (const b in blocks) { + if (!blocks.hasOwnProperty(b)) continue; + blocksList.push(blocks[b]); + } + return blocksList; +}; + +/** + * Adapter between block creation events and block representation which can be + * used by the Scratch runtime. + * @param {object} e `Blockly.events.create` + * @return {Array.} List of blocks from this CREATE event. + */ +const adapter = function (e) { + // Validate input + if (typeof e !== 'object') return; + if (typeof e.xml !== 'object') return; + + return domToBlocks(html.parseDOM(e.xml.outerHTML, {decodeEntities: true})); +}; + +module.exports = adapter; diff --git a/src/engine/block-utility.js b/src/engine/block-utility.js new file mode 100644 index 00000000..6ecab07d --- /dev/null +++ b/src/engine/block-utility.js @@ -0,0 +1,153 @@ +const Thread = require('./thread'); + +/** + * @fileoverview + * Interface provided to block primitive functions for interacting with the + * runtime, thread, target, and convenient methods. + */ + +class BlockUtility { + constructor (sequencer = null, thread = null) { + /** + * A sequencer block primitives use to branch or start procedures with + * @type {?Sequencer} + */ + this.sequencer = sequencer; + + /** + * The block primitives thread with the block's target, stackFrame and + * modifiable status. + * @type {?Thread} + */ + this.thread = thread; + } + + /** + * The target the primitive is working on. + * @type {Target} + */ + get target () { + return this.thread.target; + } + + /** + * The runtime the block primitive is running in. + * @type {Runtime} + */ + get runtime () { + return this.sequencer.runtime; + } + + /** + * The stack frame used by loop and other blocks to track internal state. + * @type {object} + */ + get stackFrame () { + return this.thread.peekStackFrame().executionContext; + } + + /** + * Set the thread to yield. + */ + yield () { + this.thread.status = Thread.STATUS_YIELD; + } + + /** + * Start a branch in the current block. + * @param {number} branchNum Which branch to step to (i.e., 1, 2). + * @param {boolean} isLoop Whether this block is a loop. + */ + startBranch (branchNum, isLoop) { + this.sequencer.stepToBranch(this.thread, branchNum, isLoop); + } + + /** + * Stop all threads. + */ + stopAll () { + this.sequencer.runtime.stopAll(); + } + + /** + * Stop threads other on this target other than the thread holding the + * executed block. + */ + stopOtherTargetThreads () { + this.sequencer.runtime.stopForTarget(this.thread.target, this.thread); + } + + /** + * Stop this thread. + */ + stopThisScript () { + this.thread.stopThisScript(); + } + + /** + * Start a specified procedure on this thread. + * @param {string} procedureCode Procedure code for procedure to start. + */ + startProcedure (procedureCode) { + this.sequencer.stepToProcedure(this.thread, procedureCode); + } + + /** + * Get names for parameters for the given procedure. + * @param {string} procedureCode Procedure code for procedure to query. + * @return {Array.} List of param names for a procedure. + */ + getProcedureParamNamesAndIds (procedureCode) { + return this.thread.target.blocks.getProcedureParamNamesAndIds(procedureCode); + } + + /** + * Store a procedure parameter value by its name. + * @param {string} paramName The procedure's parameter name. + * @param {*} paramValue The procedure's parameter value. + */ + pushParam (paramName, paramValue) { + this.thread.pushParam(paramName, paramValue); + } + + /** + * Retrieve the stored parameter value for a given parameter name. + * @param {string} paramName The procedure's parameter name. + * @return {*} The parameter's current stored value. + */ + getParam (paramName) { + return this.thread.getParam(paramName); + } + + /** + * Start all relevant hats. + * @param {!string} requestedHat Opcode of hats to start. + * @param {object=} optMatchFields Optionally, fields to match on the hat. + * @param {Target=} optTarget Optionally, a target to restrict to. + * @return {Array.} List of threads started by this function. + */ + startHats (requestedHat, optMatchFields, optTarget) { + return ( + this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget) + ); + } + + /** + * Query a named IO device. + * @param {string} device The name of like the device, like keyboard. + * @param {string} func The name of the device's function to query. + * @param {Array.<*>} args Arguments to pass to the device's function. + * @return {*} The expected output for the device's function. + */ + ioQuery (device, func, args) { + // Find the I/O device and execute the query/function call. + if ( + this.sequencer.runtime.ioDevices[device] && + this.sequencer.runtime.ioDevices[device][func]) { + const devObject = this.sequencer.runtime.ioDevices[device]; + return devObject[func].apply(devObject, args); + } + } +} + +module.exports = BlockUtility; diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 0e89025e..b889f201 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -1,6 +1,9 @@ -var adapter = require('./adapter'); -var mutationAdapter = require('./mutation-adapter'); -var xmlEscape = require('../util/xml-escape'); +const adapter = require('./adapter'); +const mutationAdapter = require('./mutation-adapter'); +const xmlEscape = require('../util/xml-escape'); +const MonitorRecord = require('./monitor-record'); +const Clone = require('../util/clone'); +const {Map} = require('immutable'); /** * @fileoverview @@ -8,487 +11,690 @@ var xmlEscape = require('../util/xml-escape'); * and handle updates from Scratch Blocks events. */ -function Blocks () { - /** - * All blocks in the workspace. - * Keys are block IDs, values are metadata about the block. - * @type {Object.} - */ - this._blocks = {}; +class Blocks { + constructor () { + /** + * All blocks in the workspace. + * Keys are block IDs, values are metadata about the block. + * @type {Object.} + */ + this._blocks = {}; + + /** + * All top-level scripts in the workspace. + * A list of block IDs that represent scripts (i.e., first block in script). + * @type {Array.} + */ + this._scripts = []; + + /** + * Runtime Cache + * @type {{inputs: {}, procedureParamNames: {}, procedureDefinitions: {}}} + * @private + */ + this._cache = { + /** + * Cache block inputs by block id + * @type {object.>} + */ + inputs: {}, + /** + * Cache procedure Param Names by block id + * @type {object.>} + */ + procedureParamNames: {}, + /** + * Cache procedure definitions by block id + * @type {object.} + */ + procedureDefinitions: {} + }; + + } /** - * All top-level scripts in the workspace. - * A list of block IDs that represent scripts (i.e., first block in script). - * @type {Array.} + * Blockly inputs that represent statements/branch. + * are prefixed with this string. + * @const{string} */ - this._scripts = []; -} - -/** - * Blockly inputs that represent statements/branch. - * are prefixed with this string. - * @const{string} - */ -Blocks.BRANCH_INPUT_PREFIX = 'SUBSTACK'; - -/** - * Provide an object with metadata for the requested block ID. - * @param {!string} blockId ID of block we have stored. - * @return {?Object} Metadata about the block, if it exists. - */ -Blocks.prototype.getBlock = function (blockId) { - return this._blocks[blockId]; -}; - -/** - * Get all known top-level blocks that start scripts. - * @return {Array.} List of block IDs. - */ -Blocks.prototype.getScripts = function () { - return this._scripts; -}; - - /** - * Get the next block for a particular block - * @param {?string} id ID of block to get the next block for - * @return {?string} ID of next block in the sequence - */ -Blocks.prototype.getNextBlock = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - return this._blocks[id].next; -}; - -/** - * Get the branch for a particular C-shaped block. - * @param {?string} id ID for block to get the branch for. - * @param {?number} branchNum Which branch to select (e.g. for if-else). - * @return {?string} ID of block in the branch. - */ -Blocks.prototype.getBranch = function (id, branchNum) { - var block = this._blocks[id]; - if (typeof block === 'undefined') return null; - if (!branchNum) branchNum = 1; - - var inputName = Blocks.BRANCH_INPUT_PREFIX; - if (branchNum > 1) { - inputName += branchNum; + static get BRANCH_INPUT_PREFIX () { + return 'SUBSTACK'; } - // Empty C-block? - if (!(inputName in block.inputs)) return null; - return block.inputs[inputName].block; -}; + /** + * Provide an object with metadata for the requested block ID. + * @param {!string} blockId ID of block we have stored. + * @return {?object} Metadata about the block, if it exists. + */ + getBlock (blockId) { + return this._blocks[blockId]; + } -/** - * Get the opcode for a particular block - * @param {?string} id ID of block to query - * @return {?string} the opcode corresponding to that block - */ -Blocks.prototype.getOpcode = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - return this._blocks[id].opcode; -}; + /** + * Get all known top-level blocks that start scripts. + * @return {Array.} List of block IDs. + */ + getScripts () { + return this._scripts; + } -/** - * Get all fields and their values for a block. - * @param {?string} id ID of block to query. - * @return {!Object} All fields and their values. - */ -Blocks.prototype.getFields = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - return this._blocks[id].fields; -}; + /** + * Get the next block for a particular block + * @param {?string} id ID of block to get the next block for + * @return {?string} ID of next block in the sequence + */ + getNextBlock (id) { + const block = this._blocks[id]; + return (typeof block === 'undefined') ? null : block.next; + } -/** - * Get all non-branch inputs for a block. - * @param {?string} id ID of block to query. - * @return {!Object} All non-branch inputs and their associated blocks. - */ -Blocks.prototype.getInputs = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - var inputs = {}; - for (var input in this._blocks[id].inputs) { - // Ignore blocks prefixed with branch prefix. - if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) - != Blocks.BRANCH_INPUT_PREFIX) { - inputs[input] = this._blocks[id].inputs[input]; + /** + * Get the branch for a particular C-shaped block. + * @param {?string} id ID for block to get the branch for. + * @param {?number} branchNum Which branch to select (e.g. for if-else). + * @return {?string} ID of block in the branch. + */ + getBranch (id, branchNum) { + const block = this._blocks[id]; + if (typeof block === 'undefined') return null; + if (!branchNum) branchNum = 1; + + let inputName = Blocks.BRANCH_INPUT_PREFIX; + if (branchNum > 1) { + inputName += branchNum; } + + // Empty C-block? + const input = block.inputs[inputName]; + return (typeof input === 'undefined') ? null : input.block; } - return inputs; -}; -/** - * Get mutation data for a block. - * @param {?string} id ID of block to query. - * @return {!Object} Mutation for the block. - */ -Blocks.prototype.getMutation = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - return this._blocks[id].mutation; -}; - -/** - * Get the top-level script for a given block. - * @param {?string} id ID of block to query. - * @return {?string} ID of top-level script block. - */ -Blocks.prototype.getTopLevelScript = function (id) { - if (typeof this._blocks[id] === 'undefined') return null; - var block = this._blocks[id]; - while (block.parent !== null) { - block = this._blocks[block.parent]; + /** + * Get the opcode for a particular block + * @param {?object} block The block to query + * @return {?string} the opcode corresponding to that block + */ + getOpcode (block) { + return (typeof block === 'undefined') ? null : block.opcode; } - return block.id; -}; -/** - * Get the procedure definition for a given name. - * @param {?string} name Name of procedure to query. - * @return {?string} ID of procedure definition. - */ -Blocks.prototype.getProcedureDefinition = function (name) { - for (var id in this._blocks) { - var block = this._blocks[id]; - if ((block.opcode == 'procedures_defnoreturn' || - block.opcode == 'procedures_defreturn') && - block.mutation.proccode == name) { - return id; + /** + * Get all fields and their values for a block. + * @param {?object} block The block to query. + * @return {?object} All fields and their values. + */ + getFields (block) { + return (typeof block === 'undefined') ? null : block.fields; + } + + /** + * Get all non-branch inputs for a block. + * @param {?object} block the block to query. + * @return {?Array.} All non-branch inputs and their associated blocks. + */ + getInputs (block) { + if (typeof block === 'undefined') return null; + let inputs = this._cache.inputs[block.id]; + if (typeof inputs !== 'undefined') { + return inputs; } - } - return null; -}; -/** - * Get the procedure definition for a given name. - * @param {?string} name Name of procedure to query. - * @return {?string} ID of procedure definition. - */ -Blocks.prototype.getProcedureParamNames = function (name) { - for (var id in this._blocks) { - var block = this._blocks[id]; - if ((block.opcode == 'procedures_defnoreturn' || - block.opcode == 'procedures_defreturn') && - block.mutation.proccode == name) { - return JSON.parse(block.mutation.argumentnames); + inputs = {}; + for (const input in block.inputs) { + // Ignore blocks prefixed with branch prefix. + if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !== + Blocks.BRANCH_INPUT_PREFIX) { + inputs[input] = block.inputs[input]; + } } - } - return null; -}; -// --------------------------------------------------------------------- - -/** - * Create event listener for blocks. Handles validation and serves as a generic - * adapter between the blocks and the runtime interface. - * @param {Object} e Blockly "block" event - * @param {?Runtime} opt_runtime Optional runtime to forward click events to. - */ - -Blocks.prototype.blocklyListen = function (e, opt_runtime) { - // Validate event - if (typeof e !== 'object') return; - if (typeof e.blockId !== 'string') return; - - // UI event: clicked scripts toggle in the runtime. - if (e.element === 'stackclick') { - if (opt_runtime) { - opt_runtime.toggleScript(e.blockId); - } - return; + this._cache.inputs[block.id] = inputs; + return inputs; } - // Block create/update/destroy - switch (e.type) { - case 'create': - var newBlocks = adapter(e); - // A create event can create many blocks. Add them all. - for (var i = 0; i < newBlocks.length; i++) { - this.createBlock(newBlocks[i]); + /** + * Get mutation data for a block. + * @param {?object} block The block to query. + * @return {?object} Mutation for the block. + */ + getMutation (block) { + return (typeof block === 'undefined') ? null : block.mutation; + } + + /** + * Get the top-level script for a given block. + * @param {?string} id ID of block to query. + * @return {?string} ID of top-level script block. + */ + getTopLevelScript (id) { + let block = this._blocks[id]; + if (typeof block === 'undefined') return null; + while (block.parent !== null) { + block = this._blocks[block.parent]; } - break; - case 'change': - this.changeBlock({ - id: e.blockId, - element: e.element, - name: e.name, - value: e.newValue - }); - break; - case 'move': - this.moveBlock({ - id: e.blockId, - oldParent: e.oldParentId, - oldInput: e.oldInputName, - newParent: e.newParentId, - newInput: e.newInputName, - newCoordinate: e.newCoordinate - }); - break; - case 'delete': - // Don't accept delete events for missing blocks, - // or shadow blocks being obscured. - if (!this._blocks.hasOwnProperty(e.blockId) || - this._blocks[e.blockId].shadow) { + return block.id; + } + + /** + * Get the procedure definition for a given name. + * @param {?string} name Name of procedure to query. + * @return {?string} ID of procedure definition. + */ + getProcedureDefinition (name) { + const blockID = this._cache.procedureDefinitions[name]; + if (typeof blockID !== 'undefined') { + return blockID; + } + + for (const id in this._blocks) { + if (!this._blocks.hasOwnProperty(id)) continue; + const block = this._blocks[id]; + if (block.opcode === 'procedures_definition') { + const internal = this._getCustomBlockInternal(block); + if (internal && internal.mutation.proccode === name) { + this._cache.procedureDefinitions[name] = id; // The outer define block id + return id; + } + } + } + + this._cache.procedureDefinitions[name] = null; + return null; + } + + /** + * Get names of parameters for the given procedure. + * @param {?string} name Name of procedure to query. + * @return {?Array.} List of param names for a procedure. + */ + getProcedureParamNamesAndIds (name) { + const cachedNames = this._cache.procedureParamNames[name]; + if (typeof cachedNames !== 'undefined') { + return cachedNames; + } + + for (const id in this._blocks) { + if (!this._blocks.hasOwnProperty(id)) continue; + const block = this._blocks[id]; + if (block.opcode === 'procedures_prototype' && + block.mutation.proccode === name) { + const names = JSON.parse(block.mutation.argumentnames); + const ids = JSON.parse(block.mutation.argumentids); + this._cache.procedureParamNames[name] = [names, ids]; + return this._cache.procedureParamNames[name]; + } + } + + this._cache.procedureParamNames[name] = null; + return null; + } + + duplicate () { + const newBlocks = new Blocks(); + newBlocks._blocks = Clone.simple(this._blocks); + newBlocks._scripts = Clone.simple(this._scripts); + return newBlocks; + } + // --------------------------------------------------------------------- + + /** + * Create event listener for blocks and variables. Handles validation and + * serves as a generic adapter between the blocks, variables, and the + * runtime interface. + * @param {object} e Blockly "block" or "variable" event + * @param {?Runtime} optRuntime Optional runtime to forward click events to. + */ + blocklyListen (e, optRuntime) { + // Validate event + if (typeof e !== 'object') return; + if (typeof e.blockId !== 'string' && typeof e.varId !== 'string') { return; } - // Inform any runtime to forget about glows on this script. - if (opt_runtime && this._blocks[e.blockId].topLevel) { - opt_runtime.quietGlow(e.blockId); - } - this.deleteBlock({ - id: e.blockId - }); - break; - } -}; + const stage = optRuntime.getTargetForStage(); -// --------------------------------------------------------------------- - -/** - * Block management: create blocks and scripts from a `create` event - * @param {!Object} block Blockly create event to be processed - */ -Blocks.prototype.createBlock = function (block) { - // Does the block already exist? - // Could happen, e.g., for an unobscured shadow. - if (this._blocks.hasOwnProperty(block.id)) { - return; - } - // Create new block. - this._blocks[block.id] = block; - // Push block id to scripts array. - // Blocks are added as a top-level stack if they are marked as a top-block - // (if they were top-level XML in the event). - if (block.topLevel) { - this._addScript(block.id); - } -}; - -/** - * Block management: change block field values - * @param {!Object} args Blockly change event to be processed - */ -Blocks.prototype.changeBlock = function (args) { - // Validate - if (args.element !== 'field' && args.element !== 'mutation') return; - if (typeof this._blocks[args.id] === 'undefined') return; - - if (args.element == 'field') { - // Update block value - if (!this._blocks[args.id].fields[args.name]) return; - this._blocks[args.id].fields[args.name].value = args.value; - } else if (args.element == 'mutation') { - this._blocks[args.id].mutation = mutationAdapter(args.value); - } -}; - -/** - * Block management: move blocks from parent to parent - * @param {!Object} e Blockly move event to be processed - */ -Blocks.prototype.moveBlock = function (e) { - if (!this._blocks.hasOwnProperty(e.id)) { - return; - } - - // Move coordinate changes. - if (e.newCoordinate) { - this._blocks[e.id].x = e.newCoordinate.x; - this._blocks[e.id].y = e.newCoordinate.y; - } - - // Remove from any old parent. - if (e.oldParent !== undefined) { - var oldParent = this._blocks[e.oldParent]; - if (e.oldInput !== undefined && - oldParent.inputs[e.oldInput].block === e.id) { - // This block was connected to the old parent's input. - oldParent.inputs[e.oldInput].block = null; - } else if (oldParent.next === e.id) { - // This block was connected to the old parent's next connection. - oldParent.next = null; - } - this._blocks[e.id].parent = null; - } - - // Has the block become a top-level block? - if (e.newParent === undefined) { - this._addScript(e.id); - } else { - // Remove script, if one exists. - this._deleteScript(e.id); - // Otherwise, try to connect it in its new place. - if (e.newInput !== undefined) { - // Moved to the new parent's input. - // Don't obscure the shadow block. - var oldShadow = null; - if (this._blocks[e.newParent].inputs.hasOwnProperty(e.newInput)) { - oldShadow = this._blocks[e.newParent].inputs[e.newInput].shadow; + // UI event: clicked scripts toggle in the runtime. + if (e.element === 'stackclick') { + if (optRuntime) { + optRuntime.toggleScript(e.blockId, {stackClick: true}); } - this._blocks[e.newParent].inputs[e.newInput] = { - name: e.newInput, - block: e.id, - shadow: oldShadow - }; + return; + } + + // Block create/update/destroy + switch (e.type) { + case 'create': { + const newBlocks = adapter(e); + // A create event can create many blocks. Add them all. + for (let i = 0; i < newBlocks.length; i++) { + this.createBlock(newBlocks[i]); + } + break; + } + case 'change': + this.changeBlock({ + id: e.blockId, + element: e.element, + name: e.name, + value: e.newValue + }, optRuntime); + break; + case 'move': + this.moveBlock({ + id: e.blockId, + oldParent: e.oldParentId, + oldInput: e.oldInputName, + newParent: e.newParentId, + newInput: e.newInputName, + newCoordinate: e.newCoordinate + }); + break; + case 'delete': + // Don't accept delete events for missing blocks, + // or shadow blocks being obscured. + if (!this._blocks.hasOwnProperty(e.blockId) || + this._blocks[e.blockId].shadow) { + return; + } + // Inform any runtime to forget about glows on this script. + if (optRuntime && this._blocks[e.blockId].topLevel) { + optRuntime.quietGlow(e.blockId); + } + this.deleteBlock(e.blockId); + break; + case 'var_create': + // New variables being created by the user are all global. + // Check if this variable exists on the current target or stage. + // If not, create it on the stage. + // TODO create global and local variables when UI provides a way. + if (optRuntime.getEditingTarget()) { + if (!optRuntime.getEditingTarget().lookupVariableById(e.varId)) { + stage.createVariable(e.varId, e.varName, e.varType); + } + } else if (!stage.lookupVariableById(e.varId)) { + // Since getEditingTarget returned null, we now need to + // explicitly check if the stage has the variable, and + // create one if not. + stage.createVariable(e.varId, e.varName, e.varType); + } + break; + case 'var_rename': + stage.renameVariable(e.varId, e.newName); + break; + case 'var_delete': + stage.deleteVariable(e.varId); + break; + } + } + + // --------------------------------------------------------------------- + + /** + * Reset all runtime caches. + */ + resetCache () { + this._cache.inputs = {}; + this._cache.procedureParamNames = {}; + this._cache.procedureDefinitions = {}; + } + + /** + * Block management: create blocks and scripts from a `create` event + * @param {!object} block Blockly create event to be processed + */ + createBlock (block) { + // Does the block already exist? + // Could happen, e.g., for an unobscured shadow. + if (this._blocks.hasOwnProperty(block.id)) { + return; + } + // Create new block. + this._blocks[block.id] = block; + // Push block id to scripts array. + // Blocks are added as a top-level stack if they are marked as a top-block + // (if they were top-level XML in the event). + if (block.topLevel) { + this._addScript(block.id); + } + + this.resetCache(); + } + + /** + * Block management: change block field values + * @param {!object} args Blockly change event to be processed + * @param {?Runtime} optRuntime Optional runtime to allow changeBlock to change VM state. + */ + changeBlock (args, optRuntime) { + // Validate + if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) return; + const block = this._blocks[args.id]; + if (typeof block === 'undefined') return; + const wasMonitored = block.isMonitored; + switch (args.element) { + case 'field': + // 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 = optRuntime.getEditingTarget().lookupVariableById(args.value); + if (variable) { + block.fields[args.name].value = variable.name; + block.fields[args.name].id = args.value; + } + } else { + // Changing the value in a dropdown + block.fields[args.name].value = args.value; + + if (!optRuntime){ + break; + } + + const flyoutBlock = block.shadow && block.parent ? this._blocks[block.parent] : block; + if (flyoutBlock.isMonitored) { + optRuntime.requestUpdateMonitor(Map({ + id: flyoutBlock.id, + params: this._getBlockParams(flyoutBlock) + })); + } + } + break; + case 'mutation': + block.mutation = mutationAdapter(args.value); + break; + case 'checkbox': { + block.isMonitored = args.value; + if (!optRuntime) { + break; + } + + const isSpriteSpecific = optRuntime.monitorBlockInfo.hasOwnProperty(block.opcode) && + optRuntime.monitorBlockInfo[block.opcode].isSpriteSpecific; + block.targetId = isSpriteSpecific ? optRuntime.getEditingTarget().id : null; + + if (wasMonitored && !block.isMonitored) { + optRuntime.requestRemoveMonitor(block.id); + } else if (!wasMonitored && block.isMonitored) { + optRuntime.requestAddMonitor(MonitorRecord({ + // @todo(vm#564) this will collide if multiple sprites use same block + id: block.id, + targetId: block.targetId, + spriteName: block.targetId ? optRuntime.getTargetById(block.targetId).getName() : null, + opcode: block.opcode, + params: this._getBlockParams(block), + // @todo(vm#565) for numerical values with decimals, some countries use comma + value: '' + })); + } + break; + } + } + + this.resetCache(); + } + + /** + * Block management: move blocks from parent to parent + * @param {!object} e Blockly move event to be processed + */ + moveBlock (e) { + if (!this._blocks.hasOwnProperty(e.id)) { + return; + } + + // Move coordinate changes. + if (e.newCoordinate) { + this._blocks[e.id].x = e.newCoordinate.x; + this._blocks[e.id].y = e.newCoordinate.y; + } + + // Remove from any old parent. + if (typeof e.oldParent !== 'undefined') { + const oldParent = this._blocks[e.oldParent]; + if (typeof e.oldInput !== 'undefined' && + oldParent.inputs[e.oldInput].block === e.id) { + // This block was connected to the old parent's input. + oldParent.inputs[e.oldInput].block = null; + } else if (oldParent.next === e.id) { + // This block was connected to the old parent's next connection. + oldParent.next = null; + } + this._blocks[e.id].parent = null; + } + + // Has the block become a top-level block? + if (typeof e.newParent === 'undefined') { + this._addScript(e.id); } else { - // Moved to the new parent's next connection. - this._blocks[e.newParent].next = e.id; - } - this._blocks[e.id].parent = e.newParent; - } -}; - -/** - * Block management: delete blocks and their associated scripts. - * @param {!Object} e Blockly delete event to be processed. - */ -Blocks.prototype.deleteBlock = function (e) { - // @todo In runtime, stop threads running on this script. - - // Get block - var block = this._blocks[e.id]; - - // Delete children - if (block.next !== null) { - this.deleteBlock({id: block.next}); - } - - // Delete inputs (including branches) - for (var input in block.inputs) { - // If it's null, the block in this input moved away. - if (block.inputs[input].block !== null) { - this.deleteBlock({id: block.inputs[input].block}); - } - // Delete obscured shadow blocks. - if (block.inputs[input].shadow !== null && - block.inputs[input].shadow !== block.inputs[input].block) { - this.deleteBlock({id: block.inputs[input].shadow}); - } - } - - // Delete any script starting with this block. - this._deleteScript(e.id); - - // Delete block itself. - delete this._blocks[e.id]; -}; - -// --------------------------------------------------------------------- - -/** - * Encode all of `this._blocks` as an XML string usable - * by a Blockly/scratch-blocks workspace. - * @return {string} String of XML representing this object's blocks. - */ -Blocks.prototype.toXML = function () { - var xmlString = ''; - for (var i = 0; i < this._scripts.length; i++) { - xmlString += this.blockToXML(this._scripts[i]); - } - return xmlString + ''; -}; - -/** - * Recursively encode an individual block and its children - * into a Blockly/scratch-blocks XML string. - * @param {!string} blockId ID of block to encode. - * @return {string} String of XML representing this block and any children. - */ -Blocks.prototype.blockToXML = function (blockId) { - var block = this._blocks[blockId]; - // Encode properties of this block. - var tagName = (block.shadow) ? 'shadow' : 'block'; - var xy = (block.topLevel) ? - ' x="' + block.x +'"' + ' y="' + block.y +'"' : - ''; - var xmlString = ''; - xmlString += '<' + tagName + - ' id="' + block.id + '"' + - ' type="' + block.opcode + '"' + - xy + - '>'; - // Add any mutation. Must come before inputs. - if (block.mutation) { - xmlString += this.mutationToXML(block.mutation); - } - // Add any inputs on this block. - for (var input in block.inputs) { - var blockInput = block.inputs[input]; - // Only encode a value tag if the value input is occupied. - if (blockInput.block || blockInput.shadow) { - xmlString += ''; - if (blockInput.block) { - xmlString += this.blockToXML(blockInput.block); + // Remove script, if one exists. + this._deleteScript(e.id); + // Otherwise, try to connect it in its new place. + if (typeof e.newInput === 'undefined') { + // Moved to the new parent's next connection. + this._blocks[e.newParent].next = e.id; + } else { + // Moved to the new parent's input. + // Don't obscure the shadow block. + let oldShadow = null; + if (this._blocks[e.newParent].inputs.hasOwnProperty(e.newInput)) { + oldShadow = this._blocks[e.newParent].inputs[e.newInput].shadow; + } + this._blocks[e.newParent].inputs[e.newInput] = { + name: e.newInput, + block: e.id, + shadow: oldShadow + }; } - if (blockInput.shadow && blockInput.shadow != blockInput.block) { - // Obscured shadow. - xmlString += this.blockToXML(blockInput.shadow); + this._blocks[e.id].parent = e.newParent; + } + this.resetCache(); + } + + + /** + * Block management: run all blocks. + * @param {!object} runtime Runtime to run all blocks in. + */ + runAllMonitored (runtime) { + Object.keys(this._blocks).forEach(blockId => { + if (this.getBlock(blockId).isMonitored) { + const targetId = this.getBlock(blockId).targetId; + runtime.addMonitorScript(blockId, targetId ? runtime.getTargetById(targetId) : null); } - xmlString += ''; + }); + } + + /** + * Block management: delete blocks and their associated scripts. Does nothing if a block + * with the given ID does not exist. + * @param {!string} blockId Id of block to delete + */ + deleteBlock (blockId) { + // @todo In runtime, stop threads running on this script. + + // Get block + const block = this._blocks[blockId]; + if (!block) { + // No block with the given ID exists + return; + } + + // Delete children + if (block.next !== null) { + this.deleteBlock(block.next); + } + + // Delete inputs (including branches) + for (const input in block.inputs) { + // If it's null, the block in this input moved away. + if (block.inputs[input].block !== null) { + this.deleteBlock(block.inputs[input].block); + } + // Delete obscured shadow blocks. + if (block.inputs[input].shadow !== null && + block.inputs[input].shadow !== block.inputs[input].block) { + this.deleteBlock(block.inputs[input].shadow); + } + } + + // Delete any script starting with this block. + this._deleteScript(blockId); + + // Delete block itself. + delete this._blocks[blockId]; + + this.resetCache(); + } + + // --------------------------------------------------------------------- + + /** + * Encode all of `this._blocks` as an XML string usable + * by a Blockly/scratch-blocks workspace. + * @return {string} String of XML representing this object's blocks. + */ + toXML () { + return this._scripts.map(script => this.blockToXML(script)).join(); + } + + /** + * Recursively encode an individual block and its children + * into a Blockly/scratch-blocks XML string. + * @param {!string} blockId ID of block to encode. + * @return {string} String of XML representing this block and any children. + */ + blockToXML (blockId) { + const block = this._blocks[blockId]; + // Encode properties of this block. + const tagName = (block.shadow) ? 'shadow' : 'block'; + let xmlString = + `<${tagName} + id="${block.id}" + type="${block.opcode}" + ${block.topLevel ? `x="${block.x}" y="${block.y}"` : ''} + >`; + // Add any mutation. Must come before inputs. + if (block.mutation) { + xmlString += this.mutationToXML(block.mutation); + } + // Add any inputs on this block. + for (const input in block.inputs) { + if (!block.inputs.hasOwnProperty(input)) continue; + const blockInput = block.inputs[input]; + // Only encode a value tag if the value input is occupied. + if (blockInput.block || blockInput.shadow) { + xmlString += ``; + if (blockInput.block) { + xmlString += this.blockToXML(blockInput.block); + } + if (blockInput.shadow && blockInput.shadow !== blockInput.block) { + // Obscured shadow. + xmlString += this.blockToXML(blockInput.shadow); + } + xmlString += ''; + } + } + // Add any fields on this block. + for (const field in block.fields) { + if (!block.fields.hasOwnProperty(field)) continue; + const blockField = block.fields[field]; + xmlString += `${value}`; + } + // Add blocks connected to the next connection. + if (block.next) { + xmlString += `${this.blockToXML(block.next)}`; + } + xmlString += ``; + return xmlString; + } + + /** + * Recursively encode a mutation object to XML. + * @param {!object} mutation Object representing a mutation. + * @return {string} XML string representing a mutation. + */ + mutationToXML (mutation) { + let mutationString = `<${mutation.tagName}`; + for (const prop in mutation) { + if (prop === 'children' || prop === 'tagName') continue; + const mutationValue = (typeof mutation[prop] === 'string') ? + xmlEscape(mutation[prop]) : mutation[prop]; + mutationString += ` ${prop}="${mutationValue}"`; + } + mutationString += '>'; + for (let i = 0; i < mutation.children.length; i++) { + mutationString += this.mutationToXML(mutation.children[i]); + } + mutationString += ``; + return mutationString; + } + + // --------------------------------------------------------------------- + /** + * Helper to serialize block fields and input fields for reporting new monitors + * @param {!object} block Block to be paramified. + * @return {!object} object of param key/values. + */ + _getBlockParams (block) { + const params = {}; + for (const key in block.fields) { + params[key] = block.fields[key].value; + } + for (const inputKey in block.inputs) { + const inputBlock = this._blocks[block.inputs[inputKey].block]; + for (const key in inputBlock.fields) { + params[key] = inputBlock.fields[key].value; + } + } + return params; + } + + /** + * Helper to get the corresponding internal procedure definition block + * @param {!object} defineBlock Outer define block. + * @return {!object} internal definition block which has the mutation. + */ + _getCustomBlockInternal (defineBlock) { + if (defineBlock.inputs && defineBlock.inputs.custom_block) { + return this._blocks[defineBlock.inputs.custom_block.block]; } } - // Add any fields on this block. - for (var field in block.fields) { - var blockField = block.fields[field]; - var value = blockField.value; - if (typeof value === 'string') { - value = xmlEscape(blockField.value); - } - xmlString += '' + - value + ''; - } - // Add blocks connected to the next connection. - if (block.next) { - xmlString += '' + this.blockToXML(block.next) + ''; - } - xmlString += ''; - return xmlString; -}; -/** - * Recursively encode a mutation object to XML. - * @param {!Object} mutation Object representing a mutation. - * @return {string} XML string representing a mutation. - */ -Blocks.prototype.mutationToXML = function (mutation) { - var mutationString = '<' + mutation.tagName; - for (var prop in mutation) { - if (prop == 'children' || prop == 'tagName') continue; - var mutationValue = (typeof mutation[prop] === 'string') ? - xmlEscape(mutation[prop]) : mutation[prop]; - mutationString += ' ' + prop + '="' + mutationValue + '"'; + /** + * Helper to add a stack to `this._scripts`. + * @param {?string} topBlockId ID of block that starts the script. + */ + _addScript (topBlockId) { + const i = this._scripts.indexOf(topBlockId); + if (i > -1) return; // Already in scripts. + this._scripts.push(topBlockId); + // Update `topLevel` property on the top block. + this._blocks[topBlockId].topLevel = true; } - mutationString += '>'; - for (var i = 0; i < mutation.children.length; i++) { - mutationString += this.mutationToXML(mutation.children[i]); + + /** + * Helper to remove a script from `this._scripts`. + * @param {?string} topBlockId ID of block that starts the script. + */ + _deleteScript (topBlockId) { + const i = this._scripts.indexOf(topBlockId); + if (i > -1) this._scripts.splice(i, 1); + // Update `topLevel` property on the top block. + if (this._blocks[topBlockId]) this._blocks[topBlockId].topLevel = false; } - mutationString += ''; - return mutationString; -}; - -// --------------------------------------------------------------------- - -/** - * Helper to add a stack to `this._scripts`. - * @param {?string} topBlockId ID of block that starts the script. - */ -Blocks.prototype._addScript = function (topBlockId) { - var i = this._scripts.indexOf(topBlockId); - if (i > -1) return; // Already in scripts. - this._scripts.push(topBlockId); - // Update `topLevel` property on the top block. - this._blocks[topBlockId].topLevel = true; -}; - -/** - * Helper to remove a script from `this._scripts`. - * @param {?string} topBlockId ID of block that starts the script. - */ -Blocks.prototype._deleteScript = function (topBlockId) { - var i = this._scripts.indexOf(topBlockId); - if (i > -1) this._scripts.splice(i, 1); - // Update `topLevel` property on the top block. - if (this._blocks[topBlockId]) this._blocks[topBlockId].topLevel = false; -}; +} module.exports = Blocks; diff --git a/src/engine/execute.js b/src/engine/execute.js index 6ef0599d..13742ec7 100644 --- a/src/engine/execute.js +++ b/src/engine/execute.js @@ -1,12 +1,101 @@ -var Thread = require('./thread'); +const BlockUtility = require('./block-utility'); +const log = require('../util/log'); +const Thread = require('./thread'); +const {Map} = require('immutable'); +const cast = require('../util/cast'); + +/** + * Single BlockUtility instance reused by execute for every pritimive ran. + * @const + */ +const blockUtility = new BlockUtility(); + +/** + * Profiler frame name for block functions. + * @const {string} + */ +const blockFunctionProfilerFrame = 'blockFunction'; + +/** + * Profiler frame ID for 'blockFunction'. + * @type {number} + */ +let blockFunctionProfilerId = -1; /** * Utility function to determine if a value is a Promise. * @param {*} value Value to check for a Promise. - * @return {Boolean} True if the value appears to be a Promise. + * @return {boolean} True if the value appears to be a Promise. */ -var isPromise = function (value) { - return value && value.then && typeof value.then === 'function'; +const isPromise = function (value) { + return ( + value !== null && + typeof value === 'object' && + typeof value.then === 'function' + ); +}; + +/** + * Handle any reported value from the primitive, either directly returned + * or after a promise resolves. + * @param {*} resolvedValue Value eventually returned from the primitive. + * @param {!Sequencer} sequencer Sequencer stepping the thread for the ran + * primitive. + * @param {!Thread} thread Thread containing the primitive. + * @param {!string} currentBlockId Id of the block in its thread for value from + * the primitive. + * @param {!string} opcode opcode used to identify a block function primitive. + * @param {!boolean} isHat Is the current block a hat? + */ +// @todo move this to callback attached to the thread when we have performance +// metrics (dd) +const handleReport = function ( + resolvedValue, sequencer, thread, currentBlockId, opcode, isHat) { + thread.pushReportedValue(resolvedValue); + if (isHat) { + // Hat predicate was evaluated. + if (sequencer.runtime.getIsEdgeActivatedHat(opcode)) { + // If this is an edge-activated hat, only proceed if the value is + // true and used to be false, or the stack was activated explicitly + // via stack click + if (!thread.stackClick) { + const oldEdgeValue = sequencer.runtime.updateEdgeActivatedValue( + currentBlockId, + resolvedValue + ); + const edgeWasActivated = !oldEdgeValue && resolvedValue; + if (!edgeWasActivated) { + sequencer.retireThread(thread); + } + } + } else if (!resolvedValue) { + // Not an edge-activated hat: retire the thread + // if predicate was false. + sequencer.retireThread(thread); + } + } else { + // In a non-hat, report the value visually if necessary if + // at the top of the thread stack. + if (typeof resolvedValue !== 'undefined' && thread.atStackTop()) { + if (thread.stackClick) { + sequencer.runtime.visualReport(currentBlockId, resolvedValue); + } + if (thread.updateMonitor) { + const targetId = sequencer.runtime.monitorBlocks.getBlock(currentBlockId).targetId; + if (targetId && !sequencer.runtime.getTargetById(targetId)) { + // Target no longer exists + return; + } + sequencer.runtime.requestUpdateMonitor(Map({ + id: currentBlockId, + spriteName: targetId ? sequencer.runtime.getTargetById(targetId).getName() : null, + value: String(resolvedValue) + })); + } + } + // Finished any yields. + thread.status = Thread.STATUS_RUNNING; + } }; /** @@ -14,127 +103,99 @@ var isPromise = function (value) { * @param {!Sequencer} sequencer Which sequencer is executing. * @param {!Thread} thread Thread which to read and execute. */ -var execute = function (sequencer, thread) { - var runtime = sequencer.runtime; - var target = thread.target; - - // Current block to execute is the one on the top of the stack. - var currentBlockId = thread.peekStack(); - var currentStackFrame = thread.peekStackFrame(); - - // Check where the block lives: target blocks or flyout blocks. - var targetHasBlock = ( - typeof target.blocks.getBlock(currentBlockId) !== 'undefined' - ); - var flyoutHasBlock = ( - typeof runtime.flyoutBlocks.getBlock(currentBlockId) !== 'undefined' - ); +const execute = function (sequencer, thread) { + const runtime = sequencer.runtime; + const target = thread.target; // Stop if block or target no longer exists. - if (!target || (!targetHasBlock && !flyoutHasBlock)) { + if (target === null) { // No block found: stop the thread; script no longer exists. sequencer.retireThread(thread); return; } - // Query info about the block. - var blockContainer = null; - if (targetHasBlock) { - blockContainer = target.blocks; + // Current block to execute is the one on the top of the stack. + const currentBlockId = thread.peekStack(); + const currentStackFrame = thread.peekStackFrame(); + + let blockContainer; + if (thread.updateMonitor) { + blockContainer = runtime.monitorBlocks; } else { - blockContainer = runtime.flyoutBlocks; + blockContainer = target.blocks; } - var opcode = blockContainer.getOpcode(currentBlockId); - var fields = blockContainer.getFields(currentBlockId); - var inputs = blockContainer.getInputs(currentBlockId); - var blockFunction = runtime.getOpcodeFunction(opcode); - var isHat = runtime.getIsHat(opcode); + let block = blockContainer.getBlock(currentBlockId); + if (typeof block === 'undefined') { + blockContainer = runtime.flyoutBlocks; + block = blockContainer.getBlock(currentBlockId); + // Stop if block or target no longer exists. + if (typeof block === 'undefined') { + // No block found: stop the thread; script no longer exists. + sequencer.retireThread(thread); + return; + } + } + + const opcode = blockContainer.getOpcode(block); + const fields = blockContainer.getFields(block); + const inputs = blockContainer.getInputs(block); + const blockFunction = runtime.getOpcodeFunction(opcode); + const isHat = runtime.getIsHat(opcode); if (!opcode) { - console.warn('Could not get opcode for block: ' + currentBlockId); + log.warn(`Could not get opcode for block: ${currentBlockId}`); return; } - /** - * Handle any reported value from the primitive, either directly returned - * or after a promise resolves. - * @param {*} resolvedValue Value eventually returned from the primitive. - */ - var handleReport = function (resolvedValue) { - thread.pushReportedValue(resolvedValue); - if (isHat) { - // Hat predicate was evaluated. - if (runtime.getIsEdgeActivatedHat(opcode)) { - // If this is an edge-activated hat, only proceed if - // the value is true and used to be false. - var oldEdgeValue = runtime.updateEdgeActivatedValue( - currentBlockId, - resolvedValue - ); - var edgeWasActivated = !oldEdgeValue && resolvedValue; - if (!edgeWasActivated) { - sequencer.retireThread(thread); - } - } else { - // Not an edge-activated hat: retire the thread - // if predicate was false. - if (!resolvedValue) { - sequencer.retireThread(thread); - } - } - } else { - // In a non-hat, report the value visually if necessary if - // at the top of the thread stack. - if (typeof resolvedValue !== 'undefined' && thread.atStackTop()) { - runtime.visualReport(currentBlockId, resolvedValue); - } - // Finished any yields. - thread.status = Thread.STATUS_RUNNING; - } - }; - // Hats and single-field shadows are implemented slightly differently // from regular blocks. // For hats: if they have an associated block function, // it's treated as a predicate; if not, execution will proceed as a no-op. // For single-field shadows: If the block has a single field, and no inputs, // immediately return the value of the field. - if (!blockFunction) { + if (typeof blockFunction === 'undefined') { if (isHat) { // Skip through the block (hat with no predicate). return; - } else { - if (Object.keys(fields).length == 1 && - Object.keys(inputs).length == 0) { - // One field and no inputs - treat as arg. - for (var fieldKey in fields) { // One iteration. - handleReport(fields[fieldKey].value); - } - } else { - console.warn('Could not get implementation for opcode: ' + - opcode); - } - thread.requestScriptGlowInFrame = true; - return; } + const keys = Object.keys(fields); + if (keys.length === 1 && Object.keys(inputs).length === 0) { + // One field and no inputs - treat as arg. + handleReport(fields[keys[0]].value, sequencer, thread, currentBlockId, opcode, isHat); + } else { + log.warn(`Could not get implementation for opcode: ${opcode}`); + } + thread.requestScriptGlowInFrame = true; + return; } // Generate values for arguments (inputs). - var argValues = {}; + const argValues = {}; // Add all fields on this block to the argValues. - for (var fieldName in fields) { - argValues[fieldName] = fields[fieldName].value; + for (const fieldName in fields) { + if (!fields.hasOwnProperty(fieldName)) continue; + if (fieldName === 'VARIABLE' || fieldName === 'LIST' || + fieldName === 'BROADCAST_OPTION') { + argValues[fieldName] = { + id: fields[fieldName].id, + name: fields[fieldName].value + }; + } else { + argValues[fieldName] = fields[fieldName].value; + } } // Recursively evaluate input blocks. - for (var inputName in inputs) { - var input = inputs[inputName]; - var inputBlockId = input.block; + for (const inputName in inputs) { + if (!inputs.hasOwnProperty(inputName)) continue; + // Do not evaluate the internal custom command block within definition + if (inputName === 'custom_block') continue; + const input = inputs[inputName]; + const inputBlockId = input.block; // Is there no value for this input waiting in the stack frame? - if (typeof currentStackFrame.reported[inputName] === 'undefined' - && inputBlockId) { + if (inputBlockId !== null && typeof currentStackFrame.reported[inputName] === 'undefined') { // If there's not, we need to evaluate the block. // Push to the stack to evaluate the reporter block. thread.pushStack(inputBlockId); @@ -144,19 +205,42 @@ var execute = function (sequencer, thread) { execute(sequencer, thread); if (thread.status === Thread.STATUS_PROMISE_WAIT) { return; - } else { - // Execution returned immediately, - // and presumably a value was reported, so pop the stack. - currentStackFrame.waitingReporter = null; - thread.popStack(); } + + // Execution returned immediately, + // and presumably a value was reported, so pop the stack. + currentStackFrame.waitingReporter = null; + thread.popStack(); + } + const inputValue = currentStackFrame.reported[inputName]; + if (inputName === 'BROADCAST_INPUT') { + const broadcastInput = inputs[inputName]; + // Check if something is plugged into the broadcast block, or + // if the shadow dropdown menu is being used. + if (broadcastInput.block === broadcastInput.shadow) { + // Shadow dropdown menu is being used. + // Get the appropriate information out of it. + const shadow = blockContainer.getBlock(broadcastInput.shadow); + const broadcastField = shadow.fields.BROADCAST_OPTION; + argValues.BROADCAST_OPTION = { + id: broadcastField.id, + name: broadcastField.value + }; + } else { + // Something is plugged into the broadcast input. + // Cast it to a string. We don't need an id here. + argValues.BROADCAST_OPTION = { + name: cast.toString(inputValue) + }; + } + } else { + argValues[inputName] = inputValue; } - argValues[inputName] = currentStackFrame.reported[inputName]; } // Add any mutation to args (e.g., for procedures). - var mutation = blockContainer.getMutation(currentBlockId); - if (mutation) { + const mutation = blockContainer.getMutation(block); + if (mutation !== null) { argValues.mutation = mutation; } @@ -166,50 +250,26 @@ var execute = function (sequencer, thread) { // (e.g., on return from a branch) gets fresh inputs. currentStackFrame.reported = {}; - var primitiveReportedValue = null; - primitiveReportedValue = blockFunction(argValues, { - stackFrame: currentStackFrame.executionContext, - target: target, - yield: function() { - thread.status = Thread.STATUS_YIELD; - }, - startBranch: function (branchNum, isLoop) { - sequencer.stepToBranch(thread, branchNum, isLoop); - }, - stopAll: function () { - runtime.stopAll(); - }, - stopOtherTargetThreads: function() { - runtime.stopForTarget(target, thread); - }, - stopThread: function() { - sequencer.retireThread(thread); - }, - startProcedure: function (procedureCode) { - sequencer.stepToProcedure(thread, procedureCode); - }, - getProcedureParamNames: function (procedureCode) { - return blockContainer.getProcedureParamNames(procedureCode); - }, - pushParam: function (paramName, paramValue) { - thread.pushParam(paramName, paramValue); - }, - getParam: function (paramName) { - return thread.getParam(paramName); - }, - startHats: function(requestedHat, opt_matchFields, opt_target) { - return ( - runtime.startHats(requestedHat, opt_matchFields, opt_target) - ); - }, - ioQuery: function (device, func, args) { - // Find the I/O device and execute the query/function call. - if (runtime.ioDevices[device] && runtime.ioDevices[device][func]) { - var devObject = runtime.ioDevices[device]; - return devObject[func].call(devObject, args); - } + let primitiveReportedValue = null; + blockUtility.sequencer = sequencer; + blockUtility.thread = thread; + if (runtime.profiler !== null) { + if (blockFunctionProfilerId === -1) { + blockFunctionProfilerId = runtime.profiler.idByName(blockFunctionProfilerFrame); } - }); + // The method commented below has its code inlined underneath to reduce + // the bias recorded for the profiler's calls in this time sensitive + // execute function. + // + // runtime.profiler.start(blockFunctionProfilerId, opcode); + runtime.profiler.records.push( + runtime.profiler.START, blockFunctionProfilerId, opcode, performance.now()); + } + primitiveReportedValue = blockFunction(argValues, blockUtility); + if (runtime.profiler !== null) { + // runtime.profiler.stop(blockFunctionProfilerId); + runtime.profiler.records.push(runtime.profiler.STOP, performance.now()); + } if (typeof primitiveReportedValue === 'undefined') { // No value reported - potentially a command block. @@ -224,24 +284,41 @@ var execute = function (sequencer, thread) { thread.status = Thread.STATUS_PROMISE_WAIT; } // Promise handlers - primitiveReportedValue.then(function(resolvedValue) { - handleReport(resolvedValue); - if (typeof resolvedValue !== 'undefined') { - thread.popStack(); - } else { - var popped = thread.popStack(); - var nextBlockId = thread.target.blocks.getNextBlock(popped); + primitiveReportedValue.then(resolvedValue => { + handleReport(resolvedValue, sequencer, thread, currentBlockId, opcode, isHat); + if (typeof resolvedValue === 'undefined') { + let stackFrame; + let nextBlockId; + do { + // In the case that the promise is the last block in the current thread stack + // We need to pop out repeatedly until we find the next block. + const popped = thread.popStack(); + if (popped === null) { + return; + } + nextBlockId = thread.target.blocks.getNextBlock(popped); + if (nextBlockId !== null) { + // A next block exists so break out this loop + break; + } + // Investigate the next block and if not in a loop, + // then repeat and pop the next item off the stack frame + stackFrame = thread.peekStackFrame(); + } while (stackFrame !== null && !stackFrame.isLoop); + thread.pushStack(nextBlockId); + } else { + thread.popStack(); } - }, function(rejectionReason) { + }, rejectionReason => { // Promise rejected: the primitive had some error. // Log it and proceed. - console.warn('Primitive rejected promise: ', rejectionReason); + log.warn('Primitive rejected promise: ', rejectionReason); thread.status = Thread.STATUS_RUNNING; thread.popStack(); }); } else if (thread.status === Thread.STATUS_RUNNING) { - handleReport(primitiveReportedValue); + handleReport(primitiveReportedValue, sequencer, thread, currentBlockId, opcode, isHat); } }; diff --git a/src/engine/list.js b/src/engine/list.js deleted file mode 100644 index 8ef082cd..00000000 --- a/src/engine/list.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @fileoverview - * Object representing a Scratch list. - */ - - /** - * @param {!string} name Name of the list. - * @param {Array} contents Contents of the list, as an array. - * @constructor - */ -function List (name, contents) { - this.name = name; - this.contents = contents; -} - -module.exports = List; diff --git a/src/engine/monitor-record.js b/src/engine/monitor-record.js new file mode 100644 index 00000000..d42b878f --- /dev/null +++ b/src/engine/monitor-record.js @@ -0,0 +1,14 @@ +const {Record} = require('immutable'); + +const MonitorRecord = Record({ + id: null, + /** Present only if the monitor is sprite-specific, such as x position */ + spriteName: null, + /** Present only if the monitor is sprite-specific, such as x position */ + targetId: null, + opcode: null, + value: null, + params: null +}); + +module.exports = MonitorRecord; diff --git a/src/engine/mutation-adapter.js b/src/engine/mutation-adapter.js index 12dc123e..d1984d30 100644 --- a/src/engine/mutation-adapter.js +++ b/src/engine/mutation-adapter.js @@ -1,13 +1,35 @@ -var html = require('htmlparser2'); +const html = require('htmlparser2'); +const decodeHtml = require('decode-html'); + +/** + * Convert a part of a mutation DOM to a mutation VM object, recursively. + * @param {object} dom DOM object for mutation tag. + * @return {object} Object representing useful parts of this mutation. + */ +const mutatorTagToObject = function (dom) { + const obj = Object.create(null); + obj.tagName = dom.name; + obj.children = []; + for (const prop in dom.attribs) { + if (prop === 'xmlns') continue; + obj[prop] = decodeHtml(dom.attribs[prop]); + } + for (let i = 0; i < dom.children.length; i++) { + obj.children.push( + mutatorTagToObject(dom.children[i]) + ); + } + return obj; +}; /** * Adapter between mutator XML or DOM and block representation which can be * used by the Scratch runtime. - * @param {(Object|string)} mutation Mutation XML string or DOM. - * @return {Object} Object representing the mutation. + * @param {(object|string)} mutation Mutation XML string or DOM. + * @return {object} Object representing the mutation. */ -module.exports = function (mutation) { - var mutationParsed; +const mutationAdpater = function (mutation) { + let mutationParsed; // Check if the mutation is already parsed; if not, parse it. if (typeof mutation === 'object') { mutationParsed = mutation; @@ -17,23 +39,4 @@ module.exports = function (mutation) { return mutatorTagToObject(mutationParsed); }; -/** - * Convert a part of a mutation DOM to a mutation VM object, recursively. - * @param {Object} dom DOM object for mutation tag. - * @return {Object} Object representing useful parts of this mutation. - */ -function mutatorTagToObject (dom) { - var obj = Object.create(null); - obj.tagName = dom.name; - obj.children = []; - for (var prop in dom.attribs) { - if (prop == 'xmlns') continue; - obj[prop] = dom.attribs[prop]; - } - for (var i = 0; i < dom.children.length; i++) { - obj.children.push( - mutatorTagToObject(dom.children[i]) - ); - } - return obj; -} +module.exports = mutationAdpater; diff --git a/src/engine/profiler.js b/src/engine/profiler.js new file mode 100644 index 00000000..1433f36f --- /dev/null +++ b/src/engine/profiler.js @@ -0,0 +1,311 @@ +/** + * @fileoverview + * A way to profile Scratch internal performance. Like what blocks run during a + * step? How much time do they take? How much time is spent inbetween blocks? + * + * Profiler aims for to spend as little time inside its functions while + * recording. For this it has a simple internal record structure that records a + * series of values for each START and STOP event in a single array. This lets + * all the values be pushed in one call for the array. This simplicity allows + * the contents of the start() and stop() calls to be inlined in areas that are + * called frequently enough to want even greater performance from Profiler so + * what is recorded better reflects on the profiled code and not Profiler + * itself. + */ + +/** + * The next id returned for a new profile'd function. + * @type {number} + */ +let nextId = 0; + +/** + * The mapping of names to ids. + * @const {Object.} + */ +const profilerNames = {}; + +/** + * The START event identifier in Profiler records. + * @const {number} + */ +const START = 0; + +/** + * The STOP event identifier in Profiler records. + * @const {number} + */ +const STOP = 1; + +/** + * The number of cells used in the records array by a START event. + * @const {number} + */ +const START_SIZE = 4; + +/** + * The number of cells used in the records array by a STOP event. + * @const {number} + */ +const STOP_SIZE = 2; + +/** + * Stored reference to Performance instance provided by the Browser. + * @const {Performance} + */ +const performance = typeof window === 'object' && window.performance; + + +/** + * Callback handle called by Profiler for each frame it decodes from its + * records. + * @callback FrameCallback + * @param {ProfilerFrame} frame + */ + +/** + * A set of information about a frame of execution that was recorded. + */ +class ProfilerFrame { + /** + * @param {number} depth Depth of the frame in the recorded stack. + */ + constructor (depth) { + /** + * The numeric id of a record symbol like Runtime._step or + * blockFunction. + * @type {number} + */ + this.id = -1; + + /** + * The amount of time spent inside the recorded frame and any deeper + * frames. + * @type {number} + */ + this.totalTime = 0; + + /** + * The amount of time spent only inside this record frame. Not + * including time in any deeper frames. + * @type {number} + */ + this.selfTime = 0; + + /** + * An arbitrary argument for the recorded frame. For example a block + * function might record its opcode as an argument. + * @type {*} + */ + this.arg = null; + + /** + * The depth of the recorded frame. This can help compare recursive + * funtions that are recorded. Each level of recursion with have a + * different depth value. + * @type {number} + */ + this.depth = depth; + } +} + +class Profiler { + /** + * @param {FrameCallback} onFrame a handle called for each recorded frame. + * The passed frame value may not be stored as it'll be updated with later + * frame information. Any information that is further stored by the handler + * should make copies or reduce the information. + */ + constructor (onFrame = function () {}) { + /** + * A series of START and STOP values followed by arguments. After + * recording is complete the full set of records is reported back by + * stepping through the series to connect the relative START and STOP + * information. + * @type {Array.<*>} + */ + this.records = []; + + /** + * A cache of ProfilerFrames to reuse when reporting the recorded + * frames in records. + * @type {Array.} + */ + this._stack = [new ProfilerFrame(0)]; + + /** + * A callback handle called with each decoded frame when reporting back + * all the recorded times. + * @type {FrameCallback} + */ + this.onFrame = onFrame; + + /** + * A reference to the START record id constant. + * @const {number} + */ + this.START = START; + + /** + * A reference to the STOP record id constant. + * @const {number} + */ + this.STOP = STOP; + } + + /** + * Start recording a frame of time for an id and optional argument. + * @param {number} id The id returned by idByName for a name symbol like + * Runtime._step. + * @param {?*} arg An arbitrary argument value to store with the frame. + */ + start (id, arg) { + this.records.push(START, id, arg, performance.now()); + } + + /** + * Stop the current frame. + */ + stop () { + this.records.push(STOP, performance.now()); + } + + /** + * Decode records and report all frames to `this.onFrame`. + */ + reportFrames () { + const stack = this._stack; + let depth = 1; + + // Step through the records and initialize Frame instances from the + // START and STOP events. START and STOP events are separated by events + // for deeper frames run by higher frames. Frames are stored on a stack + // and reinitialized for each START event. When a stop event is reach + // the Frame for the current depth has its final values stored and its + // passed to the current onFrame callback. This way Frames are "pushed" + // for each START event and "popped" for each STOP and handed to an + // outside handle to any desired reduction of the collected data. + for (let i = 0; i < this.records.length;) { + if (this.records[i] === START) { + if (depth >= stack.length) { + stack.push(new ProfilerFrame(depth)); + } + + // Store id, arg, totalTime, and initialize selfTime. + const frame = stack[depth++]; + frame.id = this.records[i + 1]; + frame.arg = this.records[i + 2]; + // totalTime is first set as the time recorded by this START + // event. Once the STOP event is reached the stored start time + // is subtracted from the recorded stop time. The resulting + // difference is the actual totalTime, and replaces the start + // time in frame.totalTime. + // + // totalTime is used this way as a convenient member to store a + // value between the two events without needing additional + // members on the Frame or in a shadow map. + frame.totalTime = this.records[i + 3]; + // selfTime is decremented until we reach the STOP event for + // this frame. totalTime will be added to it then to get the + // time difference. + frame.selfTime = 0; + + i += START_SIZE; + } else if (this.records[i] === STOP) { + const now = this.records[i + 1]; + + const frame = stack[--depth]; + // totalTime is the difference between the start event time + // stored in totalTime and the stop event time pulled from this + // record. + frame.totalTime = now - frame.totalTime; + // selfTime is the difference of this frame's totalTime and the + // sum of totalTime of deeper frames. + frame.selfTime += frame.totalTime; + + // Remove this frames totalTime from the parent's selfTime. + stack[depth - 1].selfTime -= frame.totalTime; + + this.onFrame(frame); + + i += STOP_SIZE; + } else { + this.records.length = 0; + throw new Error('Unable to decode Profiler records.'); + } + } + + this.records.length = 0; + } + + /** + * Lookup or create an id for a frame name. + * @param {string} name The name to return an id for. + * @return {number} The id for the passed name. + */ + idByName (name) { + return Profiler.idByName(name); + } + + /** + * Reverse lookup the name from a given frame id. + * @param {number} id The id to search for. + * @return {string} The name for the given id. + */ + nameById (id) { + return Profiler.nameById(id); + } + + /** + * Lookup or create an id for a frame name. + * @static + * @param {string} name The name to return an id for. + * @return {number} The id for the passed name. + */ + static idByName (name) { + if (typeof profilerNames[name] !== 'number') { + profilerNames[name] = nextId++; + } + return profilerNames[name]; + } + + /** + * Reverse lookup the name from a given frame id. + * @static + * @param {number} id The id to search for. + * @return {string} The name for the given id. + */ + static nameById (id) { + for (const name in profilerNames) { + if (profilerNames[name] === id) { + return name; + } + } + return null; + } + + /** + * Profiler is only available on platforms with the Performance API. + * @return {boolean} Can the Profiler run in this browser? + */ + static available () { + return ( + typeof window === 'object' && + typeof window.performance !== 'undefined'); + } +} + + +/** + * A reference to the START record id constant. + * @const {number} + */ +Profiler.START = START; + +/** + * A reference to the STOP record id constant. + * @const {number} + */ +Profiler.STOP = STOP; + +module.exports = Profiler; diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 14df3552..bf7969a0 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -1,850 +1,1594 @@ -var EventEmitter = require('events'); -var Sequencer = require('./sequencer'); -var Blocks = require('./blocks'); -var Thread = require('./thread'); -var util = require('util'); +const EventEmitter = require('events'); +const {OrderedMap} = require('immutable'); +const escapeHtml = require('escape-html'); + +const ArgumentType = require('../extension-support/argument-type'); +const Blocks = require('./blocks'); +const BlockType = require('../extension-support/block-type'); +const Sequencer = require('./sequencer'); +const Thread = require('./thread'); +const Profiler = require('./profiler'); // Virtual I/O devices. -var Clock = require('../io/clock'); -var Keyboard = require('../io/keyboard'); -var Mouse = require('../io/mouse'); +const Clock = require('../io/clock'); +const DeviceManager = require('../io/deviceManager'); +const Keyboard = require('../io/keyboard'); +const Mouse = require('../io/mouse'); -var defaultBlockPackages = { - 'scratch3_control': require('../blocks/scratch3_control'), - 'scratch3_event': require('../blocks/scratch3_event'), - 'scratch3_looks': require('../blocks/scratch3_looks'), - 'scratch3_motion': require('../blocks/scratch3_motion'), - 'scratch3_operators': require('../blocks/scratch3_operators'), - 'scratch3_sensing': require('../blocks/scratch3_sensing'), - 'scratch3_data': require('../blocks/scratch3_data'), - 'scratch3_procedures': require('../blocks/scratch3_procedures') +const defaultBlockPackages = { + scratch3_control: require('../blocks/scratch3_control'), + scratch3_event: require('../blocks/scratch3_event'), + scratch3_looks: require('../blocks/scratch3_looks'), + scratch3_motion: require('../blocks/scratch3_motion'), + scratch3_operators: require('../blocks/scratch3_operators'), + scratch3_sound: require('../blocks/scratch3_sound'), + scratch3_sensing: require('../blocks/scratch3_sensing'), + scratch3_data: require('../blocks/scratch3_data'), + scratch3_procedures: require('../blocks/scratch3_procedures') }; +/** + * Information used for converting Scratch argument types into scratch-blocks data. + * @type {object.}} + */ +const ArgumentTypeMap = (() => { + const map = {}; + map[ArgumentType.ANGLE] = { + shadowType: 'math_angle', + fieldType: 'NUM' + }; + map[ArgumentType.COLOR] = { + shadowType: 'colour_picker' + }; + map[ArgumentType.NUMBER] = { + shadowType: 'math_number', + fieldType: 'NUM' + }; + map[ArgumentType.STRING] = { + shadowType: 'text', + fieldType: 'TEXT' + }; + map[ArgumentType.BOOLEAN] = { + check: 'Boolean' + }; + return map; +})(); + +/** + * These constants are copied from scratch-blocks/core/constants.js + * @TODO find a way to require() these... maybe make a scratch-blocks/dist/constants.js or something like that? + * @readonly + * @enum {int} + */ +const ScratchBlocksConstants = { + /** + * ENUM for output shape: hexagonal (booleans/predicates). + * @const + */ + OUTPUT_SHAPE_HEXAGONAL: 1, + + /** + * ENUM for output shape: rounded (numbers). + * @const + */ + OUTPUT_SHAPE_ROUND: 2, + + /** + * ENUM for output shape: squared (any/all values; strings). + * @const + */ + OUTPUT_SHAPE_SQUARE: 3 +}; + +/** + * Numeric ID for Runtime._step in Profiler instances. + * @type {number} + */ +let stepProfilerId = -1; + +/** + * Numeric ID for Sequencer.stepThreads in Profiler instances. + * @type {number} + */ +let stepThreadsProfilerId = -1; + +/** + * Numeric ID for RenderWebGL.draw in Profiler instances. + * @type {number} + */ +let rendererDrawProfilerId = -1; + /** * Manages targets, scripts, and the sequencer. + * @constructor */ -function Runtime () { - // Bind event emitter - EventEmitter.call(this); +class Runtime extends EventEmitter { + constructor () { + super(); + + /** + * Target management and storage. + * @type {Array.} + */ + this.targets = []; + + /** + * A list of threads that are currently running in the VM. + * Threads are added when execution starts and pruned when execution ends. + * @type {Array.} + */ + this.threads = []; + + /** @type {!Sequencer} */ + this.sequencer = new Sequencer(this); + + /** + * Storage container for flyout blocks. + * These will execute on `_editingTarget.` + * @type {!Blocks} + */ + this.flyoutBlocks = new Blocks(); + + /** + * Storage container for monitor blocks. + * These will execute on a target maybe + * @type {!Blocks} + */ + this.monitorBlocks = new Blocks(); + + /** + * Currently known editing target for the VM. + * @type {?Target} + */ + this._editingTarget = null; + + /** + * Map to look up a block primitive's implementation function by its opcode. + * This is a two-step lookup: package name first, then primitive name. + * @type {Object.} + */ + this._primitives = {}; + + /** + * Map to look up all block information by extended opcode. + * @type {Array.} + * @private + */ + this._blockInfo = []; + + /** + * Map to look up hat blocks' metadata. + * Keys are opcode for hat, values are metadata objects. + * @type {Object.} + */ + this._hats = {}; + + /** + * Currently known values for edge-activated hats. + * Keys are block ID for the hat; values are the currently known values. + * @type {Object.} + */ + this._edgeActivatedHatValues = {}; + + /** + * A list of script block IDs that were glowing during the previous frame. + * @type {!Array.} + */ + this._scriptGlowsPreviousFrame = []; + + /** + * Number of non-monitor threads running during the previous frame. + * @type {number} + */ + this._nonMonitorThreadCount = 0; + + /** + * Currently known number of clones, used to enforce clone limit. + * @type {number} + */ + this._cloneCounter = 0; + + /** + * Flag to emit a targets update at the end of a step. When target data + * changes, this flag is set to true. + * @type {boolean} + */ + this._refreshTargets = false; + + /** + * Map to look up all monitor block information by opcode. + * @type {object} + * @private + */ + this.monitorBlockInfo = {}; + + /** + * Ordered map of all monitors, which are MonitorReporter objects. + */ + this._monitorState = OrderedMap({}); + + /** + * Monitor state from last tick + */ + this._prevMonitorState = OrderedMap({}); + + /** + * Whether the project is in "turbo mode." + * @type {Boolean} + */ + this.turboMode = false; + + /** + * Whether the project is in "compatibility mode" (30 TPS). + * @type {Boolean} + */ + this.compatibilityMode = false; + + /** + * A reference to the current runtime stepping interval, set + * by a `setInterval`. + * @type {!number} + */ + this._steppingInterval = null; + + /** + * Current length of a step. + * Changes as mode switches, and used by the sequencer to calculate + * WORK_TIME. + * @type {!number} + */ + this.currentStepTime = null; + + /** + * Whether any primitive has requested a redraw. + * Affects whether `Sequencer.stepThreads` will yield + * after stepping each thread. + * Reset on every frame. + * @type {boolean} + */ + this.redrawRequested = false; + + // Register all given block packages. + this._registerBlockPackages(); + + // Register and initialize "IO devices", containers for processing + // I/O related data. + /** @type {Object.} */ + this.ioDevices = { + clock: new Clock(), + deviceManager: new DeviceManager(), + keyboard: new Keyboard(this), + mouse: new Mouse(this) + }; + + /** + * A runtime profiler that records timed events for later playback to + * diagnose Scratch performance. + * @type {Profiler} + */ + this.profiler = null; + } /** - * Target management and storage. - * @type {Array.} + * Width of the stage, in pixels. + * @const {number} */ - this.targets = []; + static get STAGE_WIDTH () { + return 480; + } /** - * A list of threads that are currently running in the VM. - * Threads are added when execution starts and pruned when execution ends. - * @type {Array.} + * Height of the stage, in pixels. + * @const {number} */ - this.threads = []; - - /** @type {!Sequencer} */ - this.sequencer = new Sequencer(this); + static get STAGE_HEIGHT () { + return 360; + } /** - * Storage container for flyout blocks. - * These will execute on `_editingTarget.` - * @type {!Blocks} + * Event name for glowing a script. + * @const {string} */ - this.flyoutBlocks = new Blocks(); + static get SCRIPT_GLOW_ON () { + return 'SCRIPT_GLOW_ON'; + } /** - * Currently known editing target for the VM. - * @type {?Target} + * Event name for unglowing a script. + * @const {string} */ - this._editingTarget = null; + static get SCRIPT_GLOW_OFF () { + return 'SCRIPT_GLOW_OFF'; + } /** - * Map to look up a block primitive's implementation function by its opcode. - * This is a two-step lookup: package name first, then primitive name. - * @type {Object.} + * Event name for glowing a block. + * @const {string} */ - this._primitives = {}; + static get BLOCK_GLOW_ON () { + return 'BLOCK_GLOW_ON'; + } /** - * Map to look up hat blocks' metadata. - * Keys are opcode for hat, values are metadata objects. - * @type {Object.} + * Event name for unglowing a block. + * @const {string} */ - this._hats = {}; + static get BLOCK_GLOW_OFF () { + return 'BLOCK_GLOW_OFF'; + } /** - * Currently known values for edge-activated hats. - * Keys are block ID for the hat; values are the currently known values. - * @type {Object.} + * Event name when the project is started (threads may not necessarily be + * running). + * @const {string} */ - this._edgeActivatedHatValues = {}; + static get PROJECT_START () { + return 'PROJECT_START'; + } /** - * A list of script block IDs that were glowing during the previous frame. - * @type {!Array.} + * Event name when threads start running. + * Used by the UI to indicate running status. + * @const {string} */ - this._scriptGlowsPreviousFrame = []; + static get PROJECT_RUN_START () { + return 'PROJECT_RUN_START'; + } /** - * A list of block IDs that were glowing during the previous frame. - * @type {!Array.} + * Event name when threads stop running + * Used by the UI to indicate not-running status. + * @const {string} */ - this._blockGlowsPreviousFrame = []; + static get PROJECT_RUN_STOP () { + return 'PROJECT_RUN_STOP'; + } /** - * Currently known number of clones, used to enforce clone limit. - * @type {number} + * Event name for project being stopped or restarted by the user. + * Used by blocks that need to reset state. + * @const {string} */ - this._cloneCounter = 0; + static get PROJECT_STOP_ALL () { + return 'PROJECT_STOP_ALL'; + } /** - * Whether the project is in "turbo mode." - * @type {Boolean} + * Event name for visual value report. + * @const {string} */ - this.turboMode = false; + static get VISUAL_REPORT () { + return 'VISUAL_REPORT'; + } /** - * Whether the project is in "pause mode." - * @type {Boolean} + * Event name for targets update report. + * @const {string} */ - this.pauseMode = false; + static get TARGETS_UPDATE () { + return 'TARGETS_UPDATE'; + } /** - * Whether the project is in "compatibility mode" (30 TPS). - * @type {Boolean} + * Event name for monitors update. + * @const {string} */ - this.compatibilityMode = false; + static get MONITORS_UPDATE () { + return 'MONITORS_UPDATE'; + } /** - * Whether the project is in "single stepping mode." - * @type {Boolean} + * Event name for reporting that an extension was added. + * @const {string} */ - this.singleStepping = false; + static get EXTENSION_ADDED () { + return 'EXTENSION_ADDED'; + } /** - * How fast in ms "single stepping mode" should run, in ms. - * Can be updated dynamically. - * @type {!number} + * Event name for reporting that blocksInfo was updated. + * @const {string} */ - this.singleStepInterval = 1000 / 10; + static get BLOCKSINFO_UPDATE () { + return 'BLOCKSINFO_UPDATE'; + } /** - * A reference to the current runtime stepping interval, set - * by a `setInterval`. - * @type {!number} + * How rapidly we try to step threads by default, in ms. */ - this._steppingInterval = null; + static get THREAD_STEP_INTERVAL () { + return 1000 / 60; + } /** - * Current length of a step. - * Changes as mode switches, and used by the sequencer to calculate - * WORK_TIME. - * @type {!number} + * In compatibility mode, how rapidly we try to step threads, in ms. */ - this.currentStepTime = null; + static get THREAD_STEP_INTERVAL_COMPATIBILITY () { + return 1000 / 30; + } /** - * Whether any primitive has requested a redraw. - * Affects whether `Sequencer.stepThreads` will yield - * after stepping each thread. - * Reset on every frame. - * @type {boolean} + * How many clones can be created at a time. + * @const {number} */ - this.redrawRequested = false; + static get MAX_CLONES () { + return 300; + } - // Register all given block packages. - this._registerBlockPackages(); + // ----------------------------------------------------------------------------- + // ----------------------------------------------------------------------------- - // Register and initialize "IO devices", containers for processing - // I/O related data. - /** @type {Object.} */ - this.ioDevices = { - 'clock': new Clock(), - 'keyboard': new Keyboard(this), - 'mouse': new Mouse(this) - }; + /** + * Register default block packages with this runtime. + * @todo Prefix opcodes with package name. + * @private + */ + _registerBlockPackages () { + for (const packageName in defaultBlockPackages) { + if (defaultBlockPackages.hasOwnProperty(packageName)) { + // @todo pass a different runtime depending on package privilege? + const packageObject = new (defaultBlockPackages[packageName])(this); + // Collect primitives from package. + if (packageObject.getPrimitives) { + const packagePrimitives = packageObject.getPrimitives(); + for (const op in packagePrimitives) { + if (packagePrimitives.hasOwnProperty(op)) { + this._primitives[op] = + packagePrimitives[op].bind(packageObject); + } + } + } + // Collect hat metadata from package. + if (packageObject.getHats) { + const packageHats = packageObject.getHats(); + for (const hatName in packageHats) { + if (packageHats.hasOwnProperty(hatName)) { + this._hats[hatName] = packageHats[hatName]; + } + } + } + // Collect monitored from package. + if (packageObject.getMonitored) { + this.monitorBlockInfo = Object.assign({}, this.monitorBlockInfo, packageObject.getMonitored()); + } + } + } + } + + /** + * Generate an extension-specific menu ID. + * @param {string} menuName - the name of the menu. + * @param {string} extensionId - the ID of the extension hosting the menu. + * @returns {string} - the constructed ID. + * @private + */ + _makeExtensionMenuId (menuName, extensionId) { + return `${extensionId}.menu.${escapeHtml(menuName)}`; + } + + /** + * Register the primitives provided by an extension. + * @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.) + * @private + */ + _registerExtensionPrimitives (extensionInfo) { + const categoryInfo = { + id: extensionInfo.id, + name: extensionInfo.name, + blockIconURI: extensionInfo.blockIconURI, + menuIconURI: extensionInfo.menuIconURI, + color1: '#FF6680', + color2: '#FF4D6A', + color3: '#FF3355', + blocks: [], + menus: [] + }; + + this._blockInfo.push(categoryInfo); + + for (const menuName in extensionInfo.menus) { + if (extensionInfo.menus.hasOwnProperty(menuName)) { + const menuItems = extensionInfo.menus[menuName]; + const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo); + categoryInfo.menus.push(convertedMenu); + } + } + for (const blockInfo of extensionInfo.blocks) { + const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo); + const opcode = convertedBlock.json.type; + categoryInfo.blocks.push(convertedBlock); + this._primitives[opcode] = convertedBlock.info.func; + if (blockInfo.blockType === BlockType.HAT) { + this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */ + } + } + + this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus)); + } + + /** + * Reregister the primitives for an extension + * @param {ExtensionInfo} extensionInfo - new info (results of running getInfo) + * for an extension + * @private + */ + _refreshExtensionPrimitives (extensionInfo) { + let extensionBlocks = []; + for (const categoryInfo of this._blockInfo) { + if (extensionInfo.id === categoryInfo.id) { + categoryInfo.blocks = []; + categoryInfo.menus = []; + for (const menuName in extensionInfo.menus) { + if (extensionInfo.menus.hasOwnProperty(menuName)) { + const menuItems = extensionInfo.menus[menuName]; + const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo); + categoryInfo.menus.push(convertedMenu); + } + } + for (const blockInfo of extensionInfo.blocks) { + const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo); + const opcode = convertedBlock.json.type; + categoryInfo.blocks.push(convertedBlock); + this._primitives[opcode] = convertedBlock.info.func; + if (blockInfo.blockType === BlockType.HAT) { + this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */ + } + } + extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus); + } + } + + this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks); + } + + /** + * Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block. + * @param {string} menuName - the name of the menu + * @param {array} menuItems - the list of items for this menu + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {object} - a JSON-esque object ready for scratch-blocks' consumption + * @private + */ + _buildMenuForScratchBlocks (menuName, menuItems, categoryInfo) { + const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id); + + /** @TODO: support dynamic menus when 'menuItems' is a method name string (see extension spec) */ + if (typeof menuItems === 'string') { + throw new Error(`Dynamic extension menus are not yet supported. Menu name: ${menuName}`); + } + const options = menuItems.map(item => { + switch (typeof item) { + case 'string': + return [item, item]; + case 'object': + return [item.text, item.value]; + default: + throw new Error(`Can't interpret menu item: ${item}`); + } + }); + + return { + json: { + message0: '%1', + type: menuId, + inputsInline: true, + output: 'String', + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + outputShape: ScratchBlocksConstants.OUTPUT_SHAPE_ROUND, + args0: [ + { + type: 'field_dropdown', + name: menuName, + options: options + } + ] + } + }; + } + + /** + * Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function. + * @param {BlockInfo} blockInfo - the block to convert + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information + * @private + */ + _convertForScratchBlocks (blockInfo, categoryInfo) { + const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`; + const blockJSON = { + type: extendedOpcode, + inputsInline: true, + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + args0: [], + extensions: ['scratch_extension'] + }; + + const inputList = []; + + // TODO: store this somewhere so that we can map args appropriately after translation. + // This maps an arg name to its relative position in the original (usually English) block text. + // When displaying a block in another language we'll need to run a `replace` action similar to the one below, + // but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`. + const argsMap = {}; + + blockJSON.message0 = ''; + + // If an icon for the extension exists, prepend it to each block, with a vertical separator. + if (categoryInfo.blockIconURI) { + blockJSON.message0 = '%1 %2'; + const iconJSON = { + type: 'field_image', + src: categoryInfo.blockIconURI, + width: 40, + height: 40 + }; + const separatorJSON = { + type: 'field_vertical_separator' + }; + blockJSON.args0.push(iconJSON); + blockJSON.args0.push(separatorJSON); + } + + blockJSON.message0 += blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => { + // Sanitize the placeholder to ensure valid XML + placeholder = placeholder.replace(/[<"&]/, '_'); + + const argJSON = { + type: 'input_value', + name: placeholder + }; + + const argInfo = blockInfo.arguments[placeholder] || {}; + const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; + const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? + '' : + escapeHtml(argInfo.defaultValue.toString())); + + if (argTypeInfo.check) { + argJSON.check = argTypeInfo.check; + } + + const shadowType = (argInfo.menu ? + this._makeExtensionMenuId(argInfo.menu, categoryInfo.id) : + argTypeInfo.shadowType); + const fieldType = argInfo.menu || argTypeInfo.fieldType; + + // is the ScratchBlocks name for a block input. + inputList.push(``); + + // The is a placeholder for a reporter and is visible when there's no reporter in this input. + // Boolean inputs don't need to specify a shadow in the XML. + if (shadowType) { + inputList.push(``); + + // is a text field that the user can type into. Some shadows, like the color picker, don't allow + // text input and therefore don't need a field element. + if (fieldType) { + inputList.push(`${defaultValue}`); + } + + inputList.push(''); + } + + inputList.push(''); + + // scratch-blocks uses 1-based argument indexing + blockJSON.args0.push(argJSON); + const argNum = blockJSON.args0.length; + argsMap[placeholder] = argNum; + return `%${argNum}`; + }); + + switch (blockInfo.blockType) { + case BlockType.COMMAND: + blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE; + blockJSON.previousStatement = null; // null = available connection; undefined = hat + if (!blockInfo.isTerminal) { + blockJSON.nextStatement = null; // null = available connection; undefined = terminal + } + break; + case BlockType.REPORTER: + blockJSON.output = 'String'; // TODO: distinguish number & string here? + blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_ROUND; + break; + case BlockType.BOOLEAN: + blockJSON.output = 'Boolean'; + blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL; + break; + case BlockType.HAT: + blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE; + blockJSON.nextStatement = null; // null = available connection; undefined = terminal + break; + case BlockType.CONDITIONAL: + // Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ... + for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) { + blockJSON[`message${branchNum}`] = '%1'; + blockJSON[`args${branchNum}`] = [{ + type: 'input_statement', + name: `SUBSTACK${branchNum > 1 ? branchNum : ''}` + }]; + } + blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE; + blockJSON.previousStatement = null; // null = available connection; undefined = hat + blockJSON.nextStatement = null; // null = available connection; undefined = terminal + break; + } + + if (blockInfo.isTerminal) { + delete blockJSON.nextStatement; + } + + const blockXML = `${inputList.join('')}`; + + return { + info: blockInfo, + json: blockJSON, + xml: blockXML + }; + } + + /** + * @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in elements. + */ + getBlocksXML () { + const xmlParts = []; + for (const categoryInfo of this._blockInfo) { + const {name, color1, color2} = categoryInfo; + const paletteBlocks = categoryInfo.blocks.filter(block => !block.info.hideFromPalette); + const colorXML = `colour="${color1}" secondaryColour="${color2}"`; + + // Use a menu icon if there is one. Otherwise, use the block icon. If there's no icon, + // the category menu will show its default colored circle. + let menuIconURI = ''; + if (categoryInfo.menuIconURI) { + menuIconURI = categoryInfo.menuIconURI; + } else if (categoryInfo.blockIconURI) { + menuIconURI = categoryInfo.blockIconURI; + } + const menuIconXML = menuIconURI ? + `iconURI="${menuIconURI}"` : ''; + + xmlParts.push(``); + xmlParts.push.apply(xmlParts, paletteBlocks.map(block => block.xml)); + xmlParts.push(''); + } + return xmlParts.join('\n'); + } + + /** + * @returns {Array.} - an array containing the scratch-blocks JSON information for each dynamic block. + */ + getBlocksJSON () { + return this._blockInfo.reduce( + (result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []); + } + + /** + * Retrieve the function associated with the given opcode. + * @param {!string} opcode The opcode to look up. + * @return {Function} The function which implements the opcode. + */ + getOpcodeFunction (opcode) { + return this._primitives[opcode]; + } + + /** + * Return whether an opcode represents a hat block. + * @param {!string} opcode The opcode to look up. + * @return {boolean} True if the op is known to be a hat. + */ + getIsHat (opcode) { + return this._hats.hasOwnProperty(opcode); + } + + /** + * Return whether an opcode represents an edge-activated hat block. + * @param {!string} opcode The opcode to look up. + * @return {boolean} True if the op is known to be a edge-activated hat. + */ + getIsEdgeActivatedHat (opcode) { + return this._hats.hasOwnProperty(opcode) && + this._hats[opcode].edgeActivated; + } + + /** + * Update an edge-activated hat block value. + * @param {!string} blockId ID of hat to store value for. + * @param {*} newValue Value to store for edge-activated hat. + * @return {*} The old value for the edge-activated hat. + */ + updateEdgeActivatedValue (blockId, newValue) { + const oldValue = this._edgeActivatedHatValues[blockId]; + this._edgeActivatedHatValues[blockId] = newValue; + return oldValue; + } + + /** + * Clear all edge-activaed hat values. + */ + clearEdgeActivatedValues () { + this._edgeActivatedHatValues = {}; + } + + /** + * Attach the audio engine + * @param {!AudioEngine} audioEngine The audio engine to attach + */ + attachAudioEngine (audioEngine) { + this.audioEngine = audioEngine; + } + + /** + * Attach the renderer + * @param {!RenderWebGL} renderer The renderer to attach + */ + attachRenderer (renderer) { + this.renderer = renderer; + } + + /** + * Attach the storage module + * @param {!ScratchStorage} storage The storage module to attach + */ + attachStorage (storage) { + this.storage = storage; + } + + // ----------------------------------------------------------------------------- + // ----------------------------------------------------------------------------- + + /** + * Create a thread and push it to the list of threads. + * @param {!string} id ID of block that starts the stack. + * @param {!Target} target Target to run thread on. + * @param {?object} opts optional arguments + * @param {?boolean} opts.stackClick true if the script was activated by clicking on the stack + * @param {?boolean} opts.updateMonitor true if the script should update a monitor value + * @return {!Thread} The newly created thread. + */ + _pushThread (id, target, opts) { + opts = Object.assign({ + stackClick: false, + updateMonitor: false + }, opts); + + const thread = new Thread(id); + thread.target = target; + thread.stackClick = opts.stackClick; + thread.updateMonitor = opts.updateMonitor; + + thread.pushStack(id); + this.threads.push(thread); + return thread; + } + + /** + * Stop a thread: stop running it immediately, and remove it from the thread list later. + * @param {!Thread} thread Thread object to remove from actives + */ + _stopThread (thread) { + // Mark the thread for later removal + thread.isKilled = true; + // Inform sequencer to stop executing that thread. + this.sequencer.retireThread(thread); + } + + /** + * Restart a thread in place, maintaining its position in the list of threads. + * This is used by `startHats` to and is necessary to ensure 2.0-like execution order. + * Test project: https://scratch.mit.edu/projects/130183108/ + * @param {!Thread} thread Thread object to restart. + * @return {Thread} The restarted thread. + */ + _restartThread (thread) { + const newThread = new Thread(thread.topBlock); + newThread.target = thread.target; + newThread.stackClick = thread.stackClick; + newThread.updateMonitor = thread.updateMonitor; + newThread.pushStack(thread.topBlock); + const i = this.threads.indexOf(thread); + if (i > -1) { + this.threads[i] = newThread; + return newThread; + } + this.threads.push(thread); + return thread; + } + + /** + * Return whether a thread is currently active/running. + * @param {?Thread} thread Thread object to check. + * @return {boolean} True if the thread is active/running. + */ + isActiveThread (thread) { + return ( + ( + thread.stack.length > 0 && + thread.status !== Thread.STATUS_DONE) && + this.threads.indexOf(thread) > -1); + } + + /** + * Toggle a script. + * @param {!string} topBlockId ID of block that starts the script. + * @param {?object} opts optional arguments to toggle script + * @param {?string} opts.target target ID for target to run script on. If not supplied, uses editing target. + * @param {?boolean} opts.stackClick true if the user activated the stack by clicking, false if not. This + * determines whether we show a visual report when turning on the script. + */ + toggleScript (topBlockId, opts) { + opts = Object.assign({ + target: this._editingTarget, + stackClick: false + }, opts); + // Remove any existing thread. + for (let i = 0; i < this.threads.length; i++) { + // Toggling a script that's already running turns it off + if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE) { + const blockContainer = opts.target.blocks; + const opcode = blockContainer.getOpcode(blockContainer.getBlock(topBlockId)); + + if (this.getIsEdgeActivatedHat(opcode) && this.threads[i].stackClick !== opts.stackClick) { + // Allow edge activated hat thread stack click to coexist with + // edge activated hat thread that runs every frame + continue; + } + this._stopThread(this.threads[i]); + return; + } + } + // Otherwise add it. + this._pushThread(topBlockId, opts.target, opts); + } + + /** + * Enqueue a script that when finished will update the monitor for the block. + * @param {!string} topBlockId ID of block that starts the script. + * @param {?Target} optTarget target Target to run script on. If not supplied, uses editing target. + */ + addMonitorScript (topBlockId, optTarget) { + if (!optTarget) optTarget = this._editingTarget; + for (let i = 0; i < this.threads.length; i++) { + // Don't re-add the script if it's already running + if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE && + this.threads[i].updateMonitor) { + return; + } + } + // Otherwise add it. + this._pushThread(topBlockId, optTarget, {updateMonitor: true}); + } + + /** + * Run a function `f` for all scripts in a workspace. + * `f` will be called with two parameters: + * - the top block ID of the script. + * - the target that owns the script. + * @param {!Function} f Function to call for each script. + * @param {Target=} optTarget Optionally, a target to restrict to. + */ + allScriptsDo (f, optTarget) { + let targets = this.targets; + if (optTarget) { + targets = [optTarget]; + } + for (let t = targets.length - 1; t >= 0; t--) { + const target = targets[t]; + const scripts = target.blocks.getScripts(); + for (let j = 0; j < scripts.length; j++) { + const topBlockId = scripts[j]; + f(topBlockId, target); + } + } + } + + /** + * Start all relevant hats. + * @param {!string} requestedHatOpcode Opcode of hats to start. + * @param {object=} optMatchFields Optionally, fields to match on the hat. + * @param {Target=} optTarget Optionally, a target to restrict to. + * @return {Array.} List of threads started by this function. + */ + startHats (requestedHatOpcode, + optMatchFields, optTarget) { + if (!this._hats.hasOwnProperty(requestedHatOpcode)) { + // No known hat with this opcode. + return; + } + const instance = this; + const newThreads = []; + + for (const opts in optMatchFields) { + if (!optMatchFields.hasOwnProperty(opts)) continue; + optMatchFields[opts] = optMatchFields[opts].toUpperCase(); + } + + // Consider all scripts, looking for hats with opcode `requestedHatOpcode`. + this.allScriptsDo((topBlockId, target) => { + const blocks = target.blocks; + const block = blocks.getBlock(topBlockId); + const potentialHatOpcode = block.opcode; + if (potentialHatOpcode !== requestedHatOpcode) { + // Not the right hat. + return; + } + + // Match any requested fields. + // For example: ensures that broadcasts match. + // This needs to happen before the block is evaluated + // (i.e., before the predicate can be run) because "broadcast and wait" + // needs to have a precise collection of started threads. + let hatFields = blocks.getFields(block); + + // If no fields are present, check inputs (horizontal blocks) + if (Object.keys(hatFields).length === 0) { + hatFields = {}; // don't overwrite the block's actual fields list + const hatInputs = blocks.getInputs(block); + for (const input in hatInputs) { + if (!hatInputs.hasOwnProperty(input)) continue; + const id = hatInputs[input].block; + const inpBlock = blocks.getBlock(id); + const fields = blocks.getFields(inpBlock); + Object.assign(hatFields, fields); + } + } + + if (optMatchFields) { + for (const matchField in optMatchFields) { + if (hatFields[matchField].value.toUpperCase() !== + optMatchFields[matchField]) { + // Field mismatch. + return; + } + } + } + + // Look up metadata for the relevant hat. + const hatMeta = instance._hats[requestedHatOpcode]; + if (hatMeta.restartExistingThreads) { + // If `restartExistingThreads` is true, we should stop + // any existing threads starting with the top block. + for (let i = 0; i < instance.threads.length; i++) { + if (instance.threads[i].topBlock === topBlockId && + !instance.threads[i].stackClick && // stack click threads and hat threads can coexist + instance.threads[i].target === target) { + newThreads.push(instance._restartThread(instance.threads[i])); + return; + } + } + } else { + // If `restartExistingThreads` is false, we should + // give up if any threads with the top block are running. + for (let j = 0; j < instance.threads.length; j++) { + if (instance.threads[j].topBlock === topBlockId && + instance.threads[j].target === target && + !instance.threads[j].stackClick && // stack click threads and hat threads can coexist + instance.threads[j].status !== Thread.STATUS_DONE) { + // Some thread is already running. + return; + } + } + } + // Start the thread with this top block. + newThreads.push(instance._pushThread(topBlockId, target)); + }, optTarget); + return newThreads; + } + + /** + * Dispose all targets. Return to clean state. + */ + dispose () { + this.stopAll(); + this.targets.map(this.disposeTarget, this); + } + + /** + * Dispose of a target. + * @param {!Target} disposingTarget Target to dispose of. + */ + disposeTarget (disposingTarget) { + this.targets = this.targets.filter(target => { + if (disposingTarget !== target) return true; + // Allow target to do dispose actions. + target.dispose(); + // Remove from list of targets. + return false; + }); + } + + /** + * Stop any threads acting on the target. + * @param {!Target} target Target to stop threads for. + * @param {Thread=} optThreadException Optional thread to skip. + */ + stopForTarget (target, optThreadException) { + // Stop any threads on the target. + for (let i = 0; i < this.threads.length; i++) { + if (this.threads[i] === optThreadException) { + continue; + } + if (this.threads[i].target === target) { + this._stopThread(this.threads[i]); + } + } + } + + /** + * Start all threads that start with the green flag. + */ + greenFlag () { + this.stopAll(); + this.emit(Runtime.PROJECT_START); + this.ioDevices.clock.resetProjectTimer(); + this.clearEdgeActivatedValues(); + // Inform all targets of the green flag. + for (let i = 0; i < this.targets.length; i++) { + this.targets[i].onGreenFlag(); + } + this.startHats('event_whenflagclicked'); + } + + /** + * Stop "everything." + */ + stopAll () { + // Emit stop event to allow blocks to clean up any state. + this.emit(Runtime.PROJECT_STOP_ALL); + + // Dispose all clones. + const newTargets = []; + for (let i = 0; i < this.targets.length; i++) { + this.targets[i].onStopAll(); + if (this.targets[i].hasOwnProperty('isOriginal') && + !this.targets[i].isOriginal) { + this.targets[i].dispose(); + } else { + newTargets.push(this.targets[i]); + } + } + this.targets = newTargets; + // Dispose all threads. + this.threads.forEach(thread => this._stopThread(thread)); + } + + /** + * Repeatedly run `sequencer.stepThreads` and filter out + * inactive threads after each iteration. + */ + _step () { + if (this.profiler !== null) { + if (stepProfilerId === -1) { + stepProfilerId = this.profiler.idByName('Runtime._step'); + } + this.profiler.start(stepProfilerId); + } + + // Clean up threads that were told to stop during or since the last step + this.threads = this.threads.filter(thread => !thread.isKilled); + + // Find all edge-activated hats, and add them to threads to be evaluated. + for (const hatType in this._hats) { + if (!this._hats.hasOwnProperty(hatType)) continue; + const hat = this._hats[hatType]; + if (hat.edgeActivated) { + this.startHats(hatType); + } + } + this.redrawRequested = false; + this._pushMonitors(); + if (this.profiler !== null) { + if (stepThreadsProfilerId === -1) { + stepThreadsProfilerId = this.profiler.idByName('Sequencer.stepThreads'); + } + this.profiler.start(stepThreadsProfilerId); + } + const doneThreads = this.sequencer.stepThreads(); + if (this.profiler !== null) { + this.profiler.stop(); + } + this._updateGlows(doneThreads); + // Add done threads so that even if a thread finishes within 1 frame, the green + // flag will still indicate that a script ran. + this._emitProjectRunStatus( + this.threads.length + doneThreads.length - + this._getMonitorThreadCount([...this.threads, ...doneThreads])); + if (this.renderer) { + // @todo: Only render when this.redrawRequested or clones rendered. + if (this.profiler !== null) { + if (rendererDrawProfilerId === -1) { + rendererDrawProfilerId = this.profiler.idByName('RenderWebGL.draw'); + } + this.profiler.start(rendererDrawProfilerId); + } + this.renderer.draw(); + if (this.profiler !== null) { + this.profiler.stop(); + } + } + + if (this._refreshTargets) { + this.emit(Runtime.TARGETS_UPDATE); + this._refreshTargets = false; + } + + if (!this._prevMonitorState.equals(this._monitorState)) { + this.emit(Runtime.MONITORS_UPDATE, this._monitorState); + this._prevMonitorState = this._monitorState; + } + + if (this.profiler !== null) { + this.profiler.stop(); + this.profiler.reportFrames(); + } + } + + /** + * Get the number of threads in the given array that are monitor threads (threads + * that update monitor values, and don't count as running a script). + * @param {!Array.} threads The set of threads to look through. + * @return {number} The number of monitor threads in threads. + */ + _getMonitorThreadCount (threads) { + let count = 0; + threads.forEach(thread => { + if (thread.updateMonitor) count++; + }); + return count; + } + + /** + * Queue monitor blocks to sequencer to be run. + */ + _pushMonitors () { + this.monitorBlocks.runAllMonitored(this); + } + + /** + * Set the current editing target known by the runtime. + * @param {!Target} editingTarget New editing target. + */ + setEditingTarget (editingTarget) { + this._editingTarget = editingTarget; + // Script glows must be cleared. + this._scriptGlowsPreviousFrame = []; + this._updateGlows(); + this.requestTargetsUpdate(editingTarget); + } + + /** + * Set whether we are in 30 TPS compatibility mode. + * @param {boolean} compatibilityModeOn True iff in compatibility mode. + */ + setCompatibilityMode (compatibilityModeOn) { + this.compatibilityMode = compatibilityModeOn; + if (this._steppingInterval) { + clearInterval(this._steppingInterval); + this.start(); + } + } + + /** + * Emit glows/glow clears for scripts after a single tick. + * Looks at `this.threads` and notices which have turned on/off new glows. + * @param {Array.=} optExtraThreads Optional list of inactive threads. + */ + _updateGlows (optExtraThreads) { + const searchThreads = []; + searchThreads.push.apply(searchThreads, this.threads); + if (optExtraThreads) { + searchThreads.push.apply(searchThreads, optExtraThreads); + } + // Set of scripts that request a glow this frame. + const requestedGlowsThisFrame = []; + // Final set of scripts glowing during this frame. + const finalScriptGlows = []; + // Find all scripts that should be glowing. + for (let i = 0; i < searchThreads.length; i++) { + const thread = searchThreads[i]; + const target = thread.target; + if (target === this._editingTarget) { + const blockForThread = thread.blockGlowInFrame; + if (thread.requestScriptGlowInFrame) { + let script = target.blocks.getTopLevelScript(blockForThread); + if (!script) { + // Attempt to find in flyout blocks. + script = this.flyoutBlocks.getTopLevelScript( + blockForThread + ); + } + if (script) { + requestedGlowsThisFrame.push(script); + } + } + } + } + // Compare to previous frame. + for (let j = 0; j < this._scriptGlowsPreviousFrame.length; j++) { + const previousFrameGlow = this._scriptGlowsPreviousFrame[j]; + if (requestedGlowsThisFrame.indexOf(previousFrameGlow) < 0) { + // Glow turned off. + this.glowScript(previousFrameGlow, false); + } else { + // Still glowing. + finalScriptGlows.push(previousFrameGlow); + } + } + for (let k = 0; k < requestedGlowsThisFrame.length; k++) { + const currentFrameGlow = requestedGlowsThisFrame[k]; + if (this._scriptGlowsPreviousFrame.indexOf(currentFrameGlow) < 0) { + // Glow turned on. + this.glowScript(currentFrameGlow, true); + finalScriptGlows.push(currentFrameGlow); + } + } + this._scriptGlowsPreviousFrame = finalScriptGlows; + } + + /** + * Emit run start/stop after each tick. Emits when `this.threads.length` goes + * between non-zero and zero + * + * @param {number} nonMonitorThreadCount The new nonMonitorThreadCount + */ + _emitProjectRunStatus (nonMonitorThreadCount) { + if (this._nonMonitorThreadCount === 0 && nonMonitorThreadCount > 0) { + this.emit(Runtime.PROJECT_RUN_START); + } + if (this._nonMonitorThreadCount > 0 && nonMonitorThreadCount === 0) { + this.emit(Runtime.PROJECT_RUN_STOP); + } + this._nonMonitorThreadCount = nonMonitorThreadCount; + } + + /** + * "Quiet" a script's glow: stop the VM from generating glow/unglow events + * about that script. Use when a script has just been deleted, but we may + * still be tracking glow data about it. + * @param {!string} scriptBlockId Id of top-level block in script to quiet. + */ + quietGlow (scriptBlockId) { + const index = this._scriptGlowsPreviousFrame.indexOf(scriptBlockId); + if (index > -1) { + this._scriptGlowsPreviousFrame.splice(index, 1); + } + } + + /** + * Emit feedback for block glowing (used in the sequencer). + * @param {?string} blockId ID for the block to update glow + * @param {boolean} isGlowing True to turn on glow; false to turn off. + */ + glowBlock (blockId, isGlowing) { + if (isGlowing) { + this.emit(Runtime.BLOCK_GLOW_ON, {id: blockId}); + } else { + this.emit(Runtime.BLOCK_GLOW_OFF, {id: blockId}); + } + } + + /** + * Emit feedback for script glowing. + * @param {?string} topBlockId ID for the top block to update glow + * @param {boolean} isGlowing True to turn on glow; false to turn off. + */ + glowScript (topBlockId, isGlowing) { + if (isGlowing) { + this.emit(Runtime.SCRIPT_GLOW_ON, {id: topBlockId}); + } else { + this.emit(Runtime.SCRIPT_GLOW_OFF, {id: topBlockId}); + } + } + + /** + * Emit value for reporter to show in the blocks. + * @param {string} blockId ID for the block. + * @param {string} value Value to show associated with the block. + */ + visualReport (blockId, value) { + this.emit(Runtime.VISUAL_REPORT, {id: blockId, value: String(value)}); + } + + /** + * Add a monitor to the state. If the monitor already exists in the state, + * overwrites it. + * @param {!MonitorRecord} monitor Monitor to add. + */ + requestAddMonitor (monitor) { + this._monitorState = this._monitorState.set(monitor.get('id'), monitor); + } + + /** + * Update a monitor in the state. Does nothing if the monitor does not already + * exist in the state. + * @param {!Map} monitor Monitor values to update. Values on the monitor with overwrite + * values on the old monitor with the same ID. If a value isn't defined on the new monitor, + * the old monitor will keep its old value. + */ + requestUpdateMonitor (monitor) { + const id = monitor.get('id'); + if (this._monitorState.has(id)) { + this._monitorState = + this._monitorState.set(id, this._monitorState.get(id).merge(monitor)); + } + } + + /** + * Removes a monitor from the state. Does nothing if the monitor already does + * not exist in the state. + * @param {!string} monitorId ID of the monitor to remove. + */ + requestRemoveMonitor (monitorId) { + this._monitorState = this._monitorState.delete(monitorId); + } + + /** + * Removes all monitors with the given target ID from the state. Does nothing if + * the monitor already does not exist in the state. + * @param {!string} targetId Remove all monitors with given target ID. + */ + requestRemoveMonitorByTargetId (targetId) { + this._monitorState = this._monitorState.filterNot(value => value.targetId === targetId); + } + + /** + * Get a target by its id. + * @param {string} targetId Id of target to find. + * @return {?Target} The target, if found. + */ + getTargetById (targetId) { + for (let i = 0; i < this.targets.length; i++) { + const target = this.targets[i]; + if (target.id === targetId) { + return target; + } + } + } + + /** + * Get the first original (non-clone-block-created) sprite given a name. + * @param {string} spriteName Name of sprite to look for. + * @return {?Target} Target representing a sprite of the given name. + */ + getSpriteTargetByName (spriteName) { + for (let i = 0; i < this.targets.length; i++) { + const target = this.targets[i]; + if (target.sprite && target.sprite.name === spriteName) { + return target; + } + } + } + + /** + * Get a target by its drawable id. + * @param {number} drawableID drawable id of target to find + * @return {?Target} The target, if found + */ + getTargetByDrawableId (drawableID) { + for (let i = 0; i < this.targets.length; i++) { + const target = this.targets[i]; + if (target.drawableID === drawableID) return target; + } + } + + /** + * Update the clone counter to track how many clones are created. + * @param {number} changeAmount How many clones have been created/destroyed. + */ + changeCloneCounter (changeAmount) { + this._cloneCounter += changeAmount; + } + + /** + * Return whether there are clones available. + * @return {boolean} True until the number of clones hits Runtime.MAX_CLONES. + */ + clonesAvailable () { + return this._cloneCounter < Runtime.MAX_CLONES; + } + + /** + * Report that a new target has been created, possibly by cloning an existing target. + * @param {Target} newTarget - the newly created target. + * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. + * @fires Runtime#targetWasCreated + */ + fireTargetWasCreated (newTarget, sourceTarget) { + this.emit('targetWasCreated', newTarget, sourceTarget); + } + + /** + * Report that a clone target is being removed. + * @param {Target} target - the target being removed + * @fires Runtime#targetWasRemoved + */ + fireTargetWasRemoved (target) { + this.emit('targetWasRemoved', target); + } + + /** + * Get a target representing the Scratch stage, if one exists. + * @return {?Target} The target, if found. + */ + getTargetForStage () { + for (let i = 0; i < this.targets.length; i++) { + const target = this.targets[i]; + if (target.isStage) { + return target; + } + } + } + + /** + * Get the editing target. + * @return {?Target} The editing target. + */ + getEditingTarget () { + return this._editingTarget; + } + + /** + * Tell the runtime to request a redraw. + * Use after a clone/sprite has completed some visible operation on the stage. + */ + requestRedraw () { + this.redrawRequested = true; + } + + /** + * Emit a targets update at the end of the step if the provided target is + * the original sprite + * @param {!Target} target Target requesting the targets update + */ + requestTargetsUpdate (target) { + if (!target.isOriginal) return; + this._refreshTargets = true; + } + + /** + * Set up timers to repeatedly step in a browser. + */ + start () { + let interval = Runtime.THREAD_STEP_INTERVAL; + if (this.compatibilityMode) { + interval = Runtime.THREAD_STEP_INTERVAL_COMPATIBILITY; + } + this.currentStepTime = interval; + this._steppingInterval = setInterval(() => { + this._step(); + }, interval); + } + + /** + * Turn on profiling. + * @param {Profiler/FrameCallback} onFrame A callback handle passed a + * profiling frame when the profiler reports its collected data. + */ + enableProfiling (onFrame) { + if (Profiler.available()) { + this.profiler = new Profiler(onFrame); + } + } + + /** + * Turn off profiling. + */ + disableProfiling () { + this.profiler = null; + } } /** - * Inherit from EventEmitter + * Event fired after a new target has been created, possibly by cloning an existing target. + * + * @event Runtime#targetWasCreated + * @param {Target} newTarget - the newly created target. + * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. */ -util.inherits(Runtime, EventEmitter); - -/** - * Width of the stage, in pixels. - * @const {number} - */ -Runtime.STAGE_WIDTH = 480; - -/** - * Height of the stage, in pixels. - * @const {number} - */ -Runtime.STAGE_HEIGHT = 360; - -/** - * Event name for glowing a script. - * @const {string} - */ -Runtime.SCRIPT_GLOW_ON = 'STACK_GLOW_ON'; - -/** - * Event name for unglowing a script. - * @const {string} - */ -Runtime.SCRIPT_GLOW_OFF = 'STACK_GLOW_OFF'; - -/** - * Event name for glowing a block. - * @const {string} - */ -Runtime.BLOCK_GLOW_ON = 'BLOCK_GLOW_ON'; - -/** - * Event name for unglowing a block. - * @const {string} - */ -Runtime.BLOCK_GLOW_OFF = 'BLOCK_GLOW_OFF'; - -/** - * Event name for visual value report. - * @const {string} - */ -Runtime.VISUAL_REPORT = 'VISUAL_REPORT'; - -/** - * How rapidly we try to step threads by default, in ms. - */ -Runtime.THREAD_STEP_INTERVAL = 1000 / 60; - -/** - * In compatibility mode, how rapidly we try to step threads, in ms. - */ -Runtime.THREAD_STEP_INTERVAL_COMPATIBILITY = 1000 / 30; - -/** - * How many clones can be created at a time. - * @const {number} - */ -Runtime.MAX_CLONES = 300; - -// ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- - -/** - * Register default block packages with this runtime. - * @todo Prefix opcodes with package name. - * @private - */ -Runtime.prototype._registerBlockPackages = function () { - for (var packageName in defaultBlockPackages) { - if (defaultBlockPackages.hasOwnProperty(packageName)) { - // @todo pass a different runtime depending on package privilege? - var packageObject = new (defaultBlockPackages[packageName])(this); - // Collect primitives from package. - if (packageObject.getPrimitives) { - var packagePrimitives = packageObject.getPrimitives(); - for (var op in packagePrimitives) { - if (packagePrimitives.hasOwnProperty(op)) { - this._primitives[op] = - packagePrimitives[op].bind(packageObject); - } - } - } - // Collect hat metadata from package. - if (packageObject.getHats) { - var packageHats = packageObject.getHats(); - for (var hatName in packageHats) { - if (packageHats.hasOwnProperty(hatName)) { - this._hats[hatName] = packageHats[hatName]; - } - } - } - } - } -}; - -/** - * Retrieve the function associated with the given opcode. - * @param {!string} opcode The opcode to look up. - * @return {Function} The function which implements the opcode. - */ -Runtime.prototype.getOpcodeFunction = function (opcode) { - return this._primitives[opcode]; -}; - -/** - * Return whether an opcode represents a hat block. - * @param {!string} opcode The opcode to look up. - * @return {Boolean} True if the op is known to be a hat. - */ -Runtime.prototype.getIsHat = function (opcode) { - return this._hats.hasOwnProperty(opcode); -}; - -/** - * Return whether an opcode represents an edge-activated hat block. - * @param {!string} opcode The opcode to look up. - * @return {Boolean} True if the op is known to be a edge-activated hat. - */ -Runtime.prototype.getIsEdgeActivatedHat = function (opcode) { - return this._hats.hasOwnProperty(opcode) && - this._hats[opcode].edgeActivated; -}; - -/** - * Update an edge-activated hat block value. - * @param {!string} blockId ID of hat to store value for. - * @param {*} newValue Value to store for edge-activated hat. - * @return {*} The old value for the edge-activated hat. - */ -Runtime.prototype.updateEdgeActivatedValue = function (blockId, newValue) { - var oldValue = this._edgeActivatedHatValues[blockId]; - this._edgeActivatedHatValues[blockId] = newValue; - return oldValue; -}; - -/** - * Clear all edge-activaed hat values. - */ -Runtime.prototype.clearEdgeActivatedValues = function () { - this._edgeActivatedHatValues = {}; -}; - -/** - * Attach the renderer - * @param {!RenderWebGL} renderer The renderer to attach - */ -Runtime.prototype.attachRenderer = function (renderer) { - this.renderer = renderer; -}; - -// ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- - -/** - * Create a thread and push it to the list of threads. - * @param {!string} id ID of block that starts the stack. - * @param {!Target} target Target to run thread on. - * @return {!Thread} The newly created thread. - */ -Runtime.prototype._pushThread = function (id, target) { - var thread = new Thread(id); - thread.target = target; - thread.pushStack(id); - this.threads.push(thread); - return thread; -}; - -/** - * Remove a thread from the list of threads. - * @param {?Thread} thread Thread object to remove from actives - */ -Runtime.prototype._removeThread = function (thread) { - // Inform sequencer to stop executing that thread. - this.sequencer.retireThread(thread); - // Remove from the list. - var i = this.threads.indexOf(thread); - if (i > -1) { - this.threads.splice(i, 1); - } -}; - -/** - * Return whether a thread is currently active/running. - * @param {?Thread} thread Thread object to check. - * @return {Boolean} True if the thread is active/running. - */ -Runtime.prototype.isActiveThread = function (thread) { - return this.threads.indexOf(thread) > -1; -}; - -/** - * Toggle a script. - * @param {!string} topBlockId ID of block that starts the script. - */ -Runtime.prototype.toggleScript = function (topBlockId) { - // Remove any existing thread. - for (var i = 0; i < this.threads.length; i++) { - if (this.threads[i].topBlock == topBlockId) { - this._removeThread(this.threads[i]); - return; - } - } - // Otherwise add it. - this._pushThread(topBlockId, this._editingTarget); -}; - -/** - * Run a function `f` for all scripts in a workspace. - * `f` will be called with two parameters: - * - the top block ID of the script. - * - the target that owns the script. - * @param {!Function} f Function to call for each script. - * @param {Target=} opt_target Optionally, a target to restrict to. - */ -Runtime.prototype.allScriptsDo = function (f, opt_target) { - var targets = this.targets; - if (opt_target) { - targets = [opt_target]; - } - for (var t = 0; t < targets.length; t++) { - var target = targets[t]; - var scripts = target.blocks.getScripts(); - for (var j = 0; j < scripts.length; j++) { - var topBlockId = scripts[j]; - f(topBlockId, target); - } - } -}; - -/** - * Start all relevant hats. - * @param {!string} requestedHatOpcode Opcode of hats to start. - * @param {Object=} opt_matchFields Optionally, fields to match on the hat. - * @param {Target=} opt_target Optionally, a target to restrict to. - * @return {Array.} List of threads started by this function. - */ -Runtime.prototype.startHats = function (requestedHatOpcode, - opt_matchFields, opt_target) { - if (!this._hats.hasOwnProperty(requestedHatOpcode)) { - // No known hat with this opcode. - return; - } - var instance = this; - var newThreads = []; - // Consider all scripts, looking for hats with opcode `requestedHatOpcode`. - this.allScriptsDo(function(topBlockId, target) { - var potentialHatOpcode = target.blocks.getBlock(topBlockId).opcode; - if (potentialHatOpcode !== requestedHatOpcode) { - // Not the right hat. - return; - } - // Match any requested fields. - // For example: ensures that broadcasts match. - // This needs to happen before the block is evaluated - // (i.e., before the predicate can be run) because "broadcast and wait" - // needs to have a precise collection of started threads. - var hatFields = target.blocks.getFields(topBlockId); - if (opt_matchFields) { - for (var matchField in opt_matchFields) { - if (hatFields[matchField].value !== - opt_matchFields[matchField]) { - // Field mismatch. - return; - } - } - } - // Look up metadata for the relevant hat. - var hatMeta = instance._hats[requestedHatOpcode]; - if (hatMeta.restartExistingThreads) { - // If `restartExistingThreads` is true, we should stop - // any existing threads starting with the top block. - for (var i = 0; i < instance.threads.length; i++) { - if (instance.threads[i].topBlock === topBlockId && - instance.threads[i].target == target) { - instance._removeThread(instance.threads[i]); - } - } - } else { - // If `restartExistingThreads` is false, we should - // give up if any threads with the top block are running. - for (var j = 0; j < instance.threads.length; j++) { - if (instance.threads[j].topBlock === topBlockId && - instance.threads[j].target == target) { - // Some thread is already running. - return; - } - } - } - // Start the thread with this top block. - newThreads.push(instance._pushThread(topBlockId, target)); - }, opt_target); - return newThreads; -}; - -/** - * Dispose all targets. Return to clean state. - */ -Runtime.prototype.dispose = function () { - this.stopAll(); - this.targets.map(this.disposeTarget, this); -}; - -/** - * Dispose of a target. - * @param {!Target} disposingTarget Target to dispose of. - */ -Runtime.prototype.disposeTarget = function (disposingTarget) { - this.targets = this.targets.filter(function (target) { - if (disposingTarget !== target) return true; - // Allow target to do dispose actions. - target.dispose(); - // Remove from list of targets. - return false; - }); -}; - -/** - * Stop any threads acting on the target. - * @param {!Target} target Target to stop threads for. - * @param {Thread=} opt_threadException Optional thread to skip. - */ -Runtime.prototype.stopForTarget = function (target, opt_threadException) { - // Stop any threads on the target. - for (var i = 0; i < this.threads.length; i++) { - if (this.threads[i] === opt_threadException) { - continue; - } - if (this.threads[i].target == target) { - this._removeThread(this.threads[i]); - } - } -}; - -/** - * Start all threads that start with the green flag. - */ -Runtime.prototype.greenFlag = function () { - this.stopAll(); - this.ioDevices.clock.resetProjectTimer(); - this.clearEdgeActivatedValues(); - // Inform all targets of the green flag. - for (var i = 0; i < this.targets.length; i++) { - this.targets[i].onGreenFlag(); - } - this.startHats('event_whenflagclicked'); -}; - -/** - * Stop "everything." - */ -Runtime.prototype.stopAll = function () { - // Dispose all clones. - var newTargets = []; - for (var i = 0; i < this.targets.length; i++) { - if (this.targets[i].hasOwnProperty('isOriginal') && - !this.targets[i].isOriginal) { - this.targets[i].dispose(); - } else { - newTargets.push(this.targets[i]); - } - } - this.targets = newTargets; - // Dispose all threads. - var threadsCopy = this.threads.slice(); - while (threadsCopy.length > 0) { - var poppedThread = threadsCopy.pop(); - this._removeThread(poppedThread); - } -}; - -/** - * Repeatedly run `sequencer.stepThreads` and filter out - * inactive threads after each iteration. - */ -Runtime.prototype._step = function () { - if (this.pauseMode) { - // Don't do any execution while in pause mode. - return; - } - // Find all edge-activated hats, and add them to threads to be evaluated. - for (var hatType in this._hats) { - var hat = this._hats[hatType]; - if (hat.edgeActivated) { - this.startHats(hatType); - } - } - this.redrawRequested = false; - var inactiveThreads = this.sequencer.stepThreads(); - this._updateGlows(inactiveThreads); -}; - -/** - * Set the current editing target known by the runtime. - * @param {!Target} editingTarget New editing target. - */ -Runtime.prototype.setEditingTarget = function (editingTarget) { - this._editingTarget = editingTarget; - // Script glows must be cleared. - this._scriptGlowsPreviousFrame = []; - this._updateGlows(); -}; - -/** - * Set whether we are in pause mode. - * @param {boolean} pauseModeOn True iff in pause mode. - */ -Runtime.prototype.setPauseMode = function (pauseModeOn) { - // Inform the project clock/timer to pause/resume its time. - if (this.ioDevices.clock) { - if (pauseModeOn && !this.pauseMode) { - this.ioDevices.clock.pause(); - } - if (!pauseModeOn && this.pauseMode) { - this.ioDevices.clock.resume(); - } - } - this.pauseMode = pauseModeOn; -}; - -/** - * Set whether we are in 30 TPS compatibility mode. - * @param {boolean} compatibilityModeOn True iff in compatibility mode. - */ -Runtime.prototype.setCompatibilityMode = function (compatibilityModeOn) { - this.compatibilityMode = compatibilityModeOn; - if (this._steppingInterval) { - self.clearInterval(this._steppingInterval); - this.start(); - } -}; - -/** - * Set whether we are in single-stepping mode. - * @param {boolean} singleSteppingOn True iff in single-stepping mode. - */ -Runtime.prototype.setSingleSteppingMode = function (singleSteppingOn) { - this.singleStepping = singleSteppingOn; - if (this._steppingInterval) { - self.clearInterval(this._steppingInterval); - this.start(); - } -}; - -/** - * Set the speed during single-stepping mode. - * @param {number} speed Interval length to step threads, in ms. - */ -Runtime.prototype.setSingleSteppingSpeed = function (speed) { - this.singleStepInterval = 1000 / speed; - if (this._steppingInterval) { - self.clearInterval(this._steppingInterval); - this.start(); - } -}; - -/** - * Emit glows/glow clears for blocks and scripts after a single tick. - * Looks at `this.threads` and notices which have turned on/off new glows. - * @param {Array.=} opt_extraThreads Optional list of inactive threads. - */ -Runtime.prototype._updateGlows = function (opt_extraThreads) { - var searchThreads = []; - searchThreads.push.apply(searchThreads, this.threads); - if (opt_extraThreads) { - searchThreads.push.apply(searchThreads, opt_extraThreads); - } - // Set of scripts that request a glow this frame. - var requestedGlowsThisFrame = []; - var requestedBlockGlowsThisFrame = []; - // Final set of scripts glowing during this frame. - var finalScriptGlows = []; - var finalBlockGlows = []; - // Find all scripts that should be glowing. - for (var i = 0; i < searchThreads.length; i++) { - var thread = searchThreads[i]; - var target = thread.target; - if (target == this._editingTarget) { - var blockForThread = thread.blockGlowInFrame; - if (thread.requestScriptGlowInFrame) { - var script = target.blocks.getTopLevelScript(blockForThread); - if (!script) { - // Attempt to find in flyout blocks. - script = this.flyoutBlocks.getTopLevelScript( - blockForThread - ); - } - if (script) { - requestedGlowsThisFrame.push(script); - } - } - // Only show block glows in single-stepping mode. - if (this.singleStepping && blockForThread) { - requestedBlockGlowsThisFrame.push(blockForThread); - } - } - } - // Compare to previous frame. - for (var j = 0; j < this._scriptGlowsPreviousFrame.length; j++) { - var previousFrameGlow = this._scriptGlowsPreviousFrame[j]; - if (requestedGlowsThisFrame.indexOf(previousFrameGlow) < 0) { - // Glow turned off. - this.glowScript(previousFrameGlow, false); - } else { - // Still glowing. - finalScriptGlows.push(previousFrameGlow); - } - } - for (var k = 0; k < requestedGlowsThisFrame.length; k++) { - var currentFrameGlow = requestedGlowsThisFrame[k]; - if (this._scriptGlowsPreviousFrame.indexOf(currentFrameGlow) < 0) { - // Glow turned on. - this.glowScript(currentFrameGlow, true); - finalScriptGlows.push(currentFrameGlow); - } - } - for (var m = 0; m < this._blockGlowsPreviousFrame.length; m++) { - var previousBlockGlow = this._blockGlowsPreviousFrame[m]; - if (requestedBlockGlowsThisFrame.indexOf(previousBlockGlow) < 0) { - // Glow turned off. - try { - this.glowBlock(previousBlockGlow, false); - } catch (e) { - // Block has been removed. - } - } else { - // Still glowing. - finalBlockGlows.push(previousBlockGlow); - } - } - for (var p = 0; p < requestedBlockGlowsThisFrame.length; p++) { - var currentBlockFrameGlow = requestedBlockGlowsThisFrame[p]; - if (this._blockGlowsPreviousFrame.indexOf(currentBlockFrameGlow) < 0) { - // Glow turned on. - this.glowBlock(currentBlockFrameGlow, true); - finalBlockGlows.push(currentBlockFrameGlow); - } - } - this._scriptGlowsPreviousFrame = finalScriptGlows; - this._blockGlowsPreviousFrame = finalBlockGlows; -}; - -/** - * "Quiet" a script's glow: stop the VM from generating glow/unglow events - * about that script. Use when a script has just been deleted, but we may - * still be tracking glow data about it. - * @param {!string} scriptBlockId Id of top-level block in script to quiet. - */ -Runtime.prototype.quietGlow = function (scriptBlockId) { - var index = this._scriptGlowsPreviousFrame.indexOf(scriptBlockId); - if (index > -1) { - this._scriptGlowsPreviousFrame.splice(index, 1); - } -}; - -/** - * Emit feedback for block glowing (used in the sequencer). - * @param {?string} blockId ID for the block to update glow - * @param {boolean} isGlowing True to turn on glow; false to turn off. - */ -Runtime.prototype.glowBlock = function (blockId, isGlowing) { - if (isGlowing) { - this.emit(Runtime.BLOCK_GLOW_ON, blockId); - } else { - this.emit(Runtime.BLOCK_GLOW_OFF, blockId); - } -}; - -/** - * Emit feedback for script glowing. - * @param {?string} topBlockId ID for the top block to update glow - * @param {boolean} isGlowing True to turn on glow; false to turn off. - */ -Runtime.prototype.glowScript = function (topBlockId, isGlowing) { - if (isGlowing) { - this.emit(Runtime.SCRIPT_GLOW_ON, topBlockId); - } else { - this.emit(Runtime.SCRIPT_GLOW_OFF, topBlockId); - } -}; - -/** - * Emit value for reporter to show in the blocks. - * @param {string} blockId ID for the block. - * @param {string} value Value to show associated with the block. - */ -Runtime.prototype.visualReport = function (blockId, value) { - this.emit(Runtime.VISUAL_REPORT, blockId, String(value)); -}; - -/** - * Get a target by its id. - * @param {string} targetId Id of target to find. - * @return {?Target} The target, if found. - */ -Runtime.prototype.getTargetById = function (targetId) { - for (var i = 0; i < this.targets.length; i++) { - var target = this.targets[i]; - if (target.id == targetId) { - return target; - } - } -}; - -/** - * Get the first original (non-clone-block-created) sprite given a name. - * @param {string} spriteName Name of sprite to look for. - * @return {?Target} Target representing a sprite of the given name. - */ -Runtime.prototype.getSpriteTargetByName = function (spriteName) { - for (var i = 0; i < this.targets.length; i++) { - var target = this.targets[i]; - if (target.sprite && target.sprite.name == spriteName) { - return target; - } - } -}; - -/** - * Update the clone counter to track how many clones are created. - * @param {number} changeAmount How many clones have been created/destroyed. - */ -Runtime.prototype.changeCloneCounter = function (changeAmount) { - this._cloneCounter += changeAmount; -}; - -/** - * Return whether there are clones available. - * @return {boolean} True until the number of clones hits Runtime.MAX_CLONES. - */ -Runtime.prototype.clonesAvailable = function () { - return this._cloneCounter < Runtime.MAX_CLONES; -}; - -/** - * Get a target representing the Scratch stage, if one exists. - * @return {?Target} The target, if found. - */ -Runtime.prototype.getTargetForStage = function () { - for (var i = 0; i < this.targets.length; i++) { - var target = this.targets[i]; - if (target.isStage) { - return target; - } - } -}; - -/** - * Tell the runtime to request a redraw. - * Use after a clone/sprite has completed some visible operation on the stage. - */ -Runtime.prototype.requestRedraw = function () { - this.redrawRequested = true; -}; - -/** - * Handle an animation frame from the main thread. - */ -Runtime.prototype.animationFrame = function () { - if (this.renderer) { - // @todo: Only render when this.redrawRequested or clones rendered. - this.renderer.draw(); - } -}; - -/** - * Set up timers to repeatedly step in a browser. - */ -Runtime.prototype.start = function () { - var interval = Runtime.THREAD_STEP_INTERVAL; - if (this.singleStepping) { - interval = this.singleStepInterval; - } else if (this.compatibilityMode) { - interval = Runtime.THREAD_STEP_INTERVAL_COMPATIBILITY; - } - this.currentStepTime = interval; - this._steppingInterval = self.setInterval(function() { - this._step(); - }.bind(this), interval); -}; module.exports = Runtime; diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js index 182b9ee6..2db1c6ed 100644 --- a/src/engine/sequencer.js +++ b/src/engine/sequencer.js @@ -1,254 +1,349 @@ -var Timer = require('../util/timer'); -var Thread = require('./thread'); -var execute = require('./execute.js'); - -function Sequencer (runtime) { - /** - * A utility timer for timing thread sequencing. - * @type {!Timer} - */ - this.timer = new Timer(); - - /** - * Reference to the runtime owning this sequencer. - * @type {!Runtime} - */ - this.runtime = runtime; -} +const Timer = require('../util/timer'); +const Thread = require('./thread'); +const execute = require('./execute.js'); /** - * Time to run a warp-mode thread, in ms. + * Profiler frame name for stepping a single thread. + * @const {string} + */ +const stepThreadProfilerFrame = 'Sequencer.stepThread'; + +/** + * Profiler frame name for the inner loop of stepThreads. + * @const {string} + */ +const stepThreadsInnerProfilerFrame = 'Sequencer.stepThreads#inner'; + +/** + * Profiler frame name for execute. + * @const {string} + */ +const executeProfilerFrame = 'execute'; + +/** + * Profiler frame ID for stepThreadProfilerFrame. * @type {number} */ -Sequencer.WARP_TIME = 500; +let stepThreadProfilerId = -1; /** - * Step through all threads in `this.runtime.threads`, running them in order. - * @return {Array.} List of inactive threads after stepping. + * Profiler frame ID for stepThreadsInnerProfilerFrame. + * @type {number} */ -Sequencer.prototype.stepThreads = function () { - // Work time is 75% of the thread stepping interval. - var WORK_TIME = 0.75 * this.runtime.currentStepTime; - // Start counting toward WORK_TIME. - this.timer.start(); - // Count of active threads. - var numActiveThreads = Infinity; - // Whether `stepThreads` has run through a full single tick. - var ranFirstTick = false; - var inactiveThreads = []; - // Conditions for continuing to stepping threads: - // 1. We must have threads in the list, and some must be active. - // 2. Time elapsed must be less than WORK_TIME. - // 3. Either turbo mode, or no redraw has been requested by a primitive. - while (this.runtime.threads.length > 0 && - numActiveThreads > 0 && - this.timer.timeElapsed() < WORK_TIME && - (this.runtime.turboMode || !this.runtime.redrawRequested)) { +let stepThreadsInnerProfilerId = -1; + +/** + * Profiler frame ID for executeProfilerFrame. + * @type {number} + */ +let executeProfilerId = -1; + +class Sequencer { + constructor (runtime) { + /** + * A utility timer for timing thread sequencing. + * @type {!Timer} + */ + this.timer = new Timer(); + + /** + * Reference to the runtime owning this sequencer. + * @type {!Runtime} + */ + this.runtime = runtime; + } + + /** + * Time to run a warp-mode thread, in ms. + * @type {number} + */ + static get WARP_TIME () { + return 500; + } + + /** + * Step through all threads in `this.runtime.threads`, running them in order. + * @return {Array.} List of inactive threads after stepping. + */ + stepThreads () { + // Work time is 75% of the thread stepping interval. + const WORK_TIME = 0.75 * this.runtime.currentStepTime; + // Start counting toward WORK_TIME. + this.timer.start(); + // Count of active threads. + let numActiveThreads = Infinity; + // Whether `stepThreads` has run through a full single tick. + let ranFirstTick = false; + const doneThreads = this.runtime.threads.map(() => null); + // Conditions for continuing to stepping threads: + // 1. We must have threads in the list, and some must be active. + // 2. Time elapsed must be less than WORK_TIME. + // 3. Either turbo mode, or no redraw has been requested by a primitive. + while (this.runtime.threads.length > 0 && + numActiveThreads > 0 && + this.timer.timeElapsed() < WORK_TIME && + (this.runtime.turboMode || !this.runtime.redrawRequested)) { + if (this.runtime.profiler !== null) { + if (stepThreadsInnerProfilerId === -1) { + stepThreadsInnerProfilerId = this.runtime.profiler.idByName(stepThreadsInnerProfilerFrame); + } + this.runtime.profiler.start(stepThreadsInnerProfilerId); + } + + numActiveThreads = 0; + // Attempt to run each thread one time. + for (let i = 0; i < this.runtime.threads.length; i++) { + const activeThread = this.runtime.threads[i]; + if (activeThread.stack.length === 0 || + activeThread.status === Thread.STATUS_DONE) { + // Finished with this thread. + doneThreads[i] = activeThread; + continue; + } + // A thread was removed, added or this thread was restarted. + if (doneThreads[i] !== null) { + doneThreads[i] = null; + } + if (activeThread.status === Thread.STATUS_YIELD_TICK && + !ranFirstTick) { + // Clear single-tick yield from the last call of `stepThreads`. + activeThread.status = Thread.STATUS_RUNNING; + } + if (activeThread.status === Thread.STATUS_RUNNING || + activeThread.status === Thread.STATUS_YIELD) { + // Normal-mode thread: step. + if (this.runtime.profiler !== null) { + if (stepThreadProfilerId === -1) { + stepThreadProfilerId = this.runtime.profiler.idByName(stepThreadProfilerFrame); + } + this.runtime.profiler.start(stepThreadProfilerId); + } + this.stepThread(activeThread); + if (this.runtime.profiler !== null) { + this.runtime.profiler.stop(); + } + activeThread.warpTimer = null; + if (activeThread.isKilled) { + i--; // if the thread is removed from the list (killed), do not increase index + } + } + if (activeThread.status === Thread.STATUS_RUNNING) { + numActiveThreads++; + } + } + // We successfully ticked once. Prevents running STATUS_YIELD_TICK + // threads on the next tick. + ranFirstTick = true; + + if (this.runtime.profiler !== null) { + this.runtime.profiler.stop(); + } + } + // Filter inactive threads from `this.runtime.threads`. numActiveThreads = 0; - // Inline copy of the threads, updated on each step. - var threadsCopy = this.runtime.threads.slice(); - // Attempt to run each thread one time. - for (var i = 0; i < threadsCopy.length; i++) { - var activeThread = threadsCopy[i]; - if (activeThread.stack.length === 0 || - activeThread.status === Thread.STATUS_DONE) { - // Finished with this thread. - if (inactiveThreads.indexOf(activeThread) < 0) { - inactiveThreads.push(activeThread); - } - continue; - } - if (activeThread.status === Thread.STATUS_YIELD_TICK && - !ranFirstTick) { - // Clear single-tick yield from the last call of `stepThreads`. - activeThread.status = Thread.STATUS_RUNNING; - } - if (activeThread.status === Thread.STATUS_RUNNING || - activeThread.status === Thread.STATUS_YIELD) { - // Normal-mode thread: step. - this.stepThread(activeThread); - activeThread.warpTimer = null; - } - if (activeThread.status === Thread.STATUS_RUNNING) { - // After stepping, status is still running. - // If we're in single-stepping mode, mark the thread as - // a single-tick yield so it doesn't re-execute - // until the next frame. - if (this.runtime.singleStepping) { - activeThread.status = Thread.STATUS_YIELD_TICK; - } + for (let i = 0; i < this.runtime.threads.length; i++) { + const thread = this.runtime.threads[i]; + if (doneThreads[i] === null) { + this.runtime.threads[numActiveThreads] = thread; numActiveThreads++; } } - // We successfully ticked once. Prevents running STATUS_YIELD_TICK - // threads on the next tick. - ranFirstTick = true; - } - // Filter inactive threads from `this.runtime.threads`. - this.runtime.threads = this.runtime.threads.filter(function(thread) { - if (inactiveThreads.indexOf(thread) > -1) { - return false; - } - return true; - }); - return inactiveThreads; -}; + this.runtime.threads.length = numActiveThreads; -/** - * Step the requested thread for as long as necessary. - * @param {!Thread} thread Thread object to step. - */ -Sequencer.prototype.stepThread = function (thread) { - var currentBlockId = thread.peekStack(); - if (!currentBlockId) { - // A "null block" - empty branch. - thread.popStack(); - } - while (thread.peekStack()) { - var isWarpMode = thread.peekStackFrame().warpMode; - if (isWarpMode && !thread.warpTimer) { - // Initialize warp-mode timer if it hasn't been already. - // This will start counting the thread toward `Sequencer.WARP_TIME`. - thread.warpTimer = new Timer(); - thread.warpTimer.start(); - } - // Execute the current block. - // Save the current block ID to notice if we did control flow. - currentBlockId = thread.peekStack(); - execute(this, thread); - thread.blockGlowInFrame = currentBlockId; - // If the thread has yielded or is waiting, yield to other threads. - if (thread.status === Thread.STATUS_YIELD) { - // Mark as running for next iteration. - thread.status = Thread.STATUS_RUNNING; - // In warp mode, yielded blocks are re-executed immediately. - if (isWarpMode && - thread.warpTimer.timeElapsed() <= Sequencer.WARP_TIME) { - continue; + // Filter undefined and null values from `doneThreads`. + let numDoneThreads = 0; + for (let i = 0; i < doneThreads.length; i++) { + const maybeThread = doneThreads[i]; + if (maybeThread !== null) { + doneThreads[numDoneThreads] = maybeThread; + numDoneThreads++; } - return; - } else if (thread.status === Thread.STATUS_PROMISE_WAIT) { - // A promise was returned by the primitive. Yield the thread - // until the promise resolves. Promise resolution should reset - // thread.status to Thread.STATUS_RUNNING. - return; } - // If no control flow has happened, switch to next block. - if (thread.peekStack() === currentBlockId) { - thread.goToNextBlock(); - } - // If no next block has been found at this point, look on the stack. - while (!thread.peekStack()) { + doneThreads.length = numDoneThreads; + + return doneThreads; + } + + /** + * Step the requested thread for as long as necessary. + * @param {!Thread} thread Thread object to step. + */ + stepThread (thread) { + let currentBlockId = thread.peekStack(); + if (!currentBlockId) { + // A "null block" - empty branch. thread.popStack(); - if (thread.stack.length === 0) { - // No more stack to run! - thread.status = Thread.STATUS_DONE; + } + while (thread.peekStack()) { + let isWarpMode = thread.peekStackFrame().warpMode; + if (isWarpMode && !thread.warpTimer) { + // Initialize warp-mode timer if it hasn't been already. + // This will start counting the thread toward `Sequencer.WARP_TIME`. + thread.warpTimer = new Timer(); + thread.warpTimer.start(); + } + // Execute the current block. + // Save the current block ID to notice if we did control flow. + currentBlockId = thread.peekStack(); + if (this.runtime.profiler !== null) { + if (executeProfilerId === -1) { + executeProfilerId = this.runtime.profiler.idByName(executeProfilerFrame); + } + // The method commented below has its code inlined underneath to + // reduce the bias recorded for the profiler's calls in this + // time sensitive stepThread method. + // + // this.runtime.profiler.start(executeProfilerId, null); + this.runtime.profiler.records.push( + this.runtime.profiler.START, executeProfilerId, null, performance.now()); + } + execute(this, thread); + if (this.runtime.profiler !== null) { + // this.runtime.profiler.stop(); + this.runtime.profiler.records.push(this.runtime.profiler.STOP, performance.now()); + } + thread.blockGlowInFrame = currentBlockId; + // If the thread has yielded or is waiting, yield to other threads. + if (thread.status === Thread.STATUS_YIELD) { + // Mark as running for next iteration. + thread.status = Thread.STATUS_RUNNING; + // In warp mode, yielded blocks are re-executed immediately. + if (isWarpMode && + thread.warpTimer.timeElapsed() <= Sequencer.WARP_TIME) { + continue; + } + return; + } else if (thread.status === Thread.STATUS_PROMISE_WAIT) { + // A promise was returned by the primitive. Yield the thread + // until the promise resolves. Promise resolution should reset + // thread.status to Thread.STATUS_RUNNING. return; } - if (thread.peekStackFrame().isLoop) { - // The current level of the stack is marked as a loop. - // Return to yield for the frame/tick in general. - // Unless we're in warp mode - then only return if the - // warp timer is up. - if (!isWarpMode || - thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) { - // Don't do anything to the stack, since loops need - // to be re-executed. + // If no control flow has happened, switch to next block. + if (thread.peekStack() === currentBlockId) { + thread.goToNextBlock(); + } + // If no next block has been found at this point, look on the stack. + while (!thread.peekStack()) { + thread.popStack(); + + if (thread.stack.length === 0) { + // No more stack to run! + thread.status = Thread.STATUS_DONE; return; - } else { + } + + const stackFrame = thread.peekStackFrame(); + isWarpMode = stackFrame.warpMode; + + if (stackFrame.isLoop) { + // The current level of the stack is marked as a loop. + // Return to yield for the frame/tick in general. + // Unless we're in warp mode - then only return if the + // warp timer is up. + if (!isWarpMode || + thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) { + // Don't do anything to the stack, since loops need + // to be re-executed. + return; + } // Don't go to the next block for this level of the stack, // since loops need to be re-executed. continue; + + } else if (stackFrame.waitingReporter) { + // This level of the stack was waiting for a value. + // This means a reporter has just returned - so don't go + // to the next block for this level of the stack. + return; } - } else if (thread.peekStackFrame().waitingReporter) { - // This level of the stack was waiting for a value. - // This means a reporter has just returned - so don't go - // to the next block for this level of the stack. - return; + // Get next block of existing block on the stack. + thread.goToNextBlock(); } - // Get next block of existing block on the stack. - thread.goToNextBlock(); } - // In single-stepping mode, force `stepThread` to only run one block - // at a time. - if (this.runtime.singleStepping) { + } + + /** + * Step a thread into a block's branch. + * @param {!Thread} thread Thread object to step to branch. + * @param {number} branchNum Which branch to step to (i.e., 1, 2). + * @param {boolean} isLoop Whether this block is a loop. + */ + stepToBranch (thread, branchNum, isLoop) { + if (!branchNum) { + branchNum = 1; + } + const currentBlockId = thread.peekStack(); + const branchId = thread.target.blocks.getBranch( + currentBlockId, + branchNum + ); + thread.peekStackFrame().isLoop = isLoop; + if (branchId) { + // Push branch ID to the thread's stack. + thread.pushStack(branchId); + } else { + thread.pushStack(null); + } + } + + /** + * Step a procedure. + * @param {!Thread} thread Thread object to step to procedure. + * @param {!string} procedureCode Procedure code of procedure to step to. + */ + stepToProcedure (thread, procedureCode) { + const definition = thread.target.blocks.getProcedureDefinition(procedureCode); + if (!definition) { return; } - } -}; - -/** - * Step a thread into a block's branch. - * @param {!Thread} thread Thread object to step to branch. - * @param {Number} branchNum Which branch to step to (i.e., 1, 2). - * @param {Boolean} isLoop Whether this block is a loop. - */ -Sequencer.prototype.stepToBranch = function (thread, branchNum, isLoop) { - if (!branchNum) { - branchNum = 1; - } - var currentBlockId = thread.peekStack(); - var branchId = thread.target.blocks.getBranch( - currentBlockId, - branchNum - ); - thread.peekStackFrame().isLoop = isLoop; - if (branchId) { - // Push branch ID to the thread's stack. - thread.pushStack(branchId); - } else { - thread.pushStack(null); - } -}; - -/** - * Step a procedure. - * @param {!Thread} thread Thread object to step to procedure. - * @param {!string} procedureCode Procedure code of procedure to step to. - */ -Sequencer.prototype.stepToProcedure = function (thread, procedureCode) { - var definition = thread.target.blocks.getProcedureDefinition(procedureCode); - if (!definition) { - return; - } - // Check if the call is recursive. - // If so, set the thread to yield after pushing. - var isRecursive = thread.isRecursiveCall(procedureCode); - // To step to a procedure, we put its definition on the stack. - // Execution for the thread will proceed through the definition hat - // and on to the main definition of the procedure. - // When that set of blocks finishes executing, it will be popped - // from the stack by the sequencer, returning control to the caller. - thread.pushStack(definition); - // In known warp-mode threads, only yield when time is up. - if (thread.peekStackFrame().warpMode && - thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) { - thread.status = Thread.STATUS_YIELD; - } else { - // Look for warp-mode flag on definition, and set the thread - // to warp-mode if needed. - var definitionBlock = thread.target.blocks.getBlock(definition); - var doWarp = definitionBlock.mutation.warp; - if (doWarp) { - thread.peekStackFrame().warpMode = true; + // Check if the call is recursive. + // If so, set the thread to yield after pushing. + const isRecursive = thread.isRecursiveCall(procedureCode); + // To step to a procedure, we put its definition on the stack. + // Execution for the thread will proceed through the definition hat + // and on to the main definition of the procedure. + // When that set of blocks finishes executing, it will be popped + // from the stack by the sequencer, returning control to the caller. + thread.pushStack(definition); + // In known warp-mode threads, only yield when time is up. + if (thread.peekStackFrame().warpMode && + thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) { + thread.status = Thread.STATUS_YIELD; } else { - // In normal-mode threads, yield any time we have a recursive call. - if (isRecursive) { + // Look for warp-mode flag on definition, and set the thread + // to warp-mode if needed. + const definitionBlock = thread.target.blocks.getBlock(definition); + const innerBlock = thread.target.blocks.getBlock( + definitionBlock.inputs.custom_block.block); + let doWarp = false; + if (innerBlock && innerBlock.mutation) { + const warp = innerBlock.mutation.warp; + if (typeof warp === 'boolean') { + doWarp = warp; + } else if (typeof warp === 'string') { + doWarp = JSON.parse(warp); + } + } + if (doWarp) { + thread.peekStackFrame().warpMode = true; + } else if (isRecursive) { + // In normal-mode threads, yield any time we have a recursive call. thread.status = Thread.STATUS_YIELD; } } } -}; -/** - * Retire a thread in the middle, without considering further blocks. - * @param {!Thread} thread Thread object to retire. - */ -Sequencer.prototype.retireThread = function (thread) { - thread.stack = []; - thread.stackFrame = []; - thread.requestScriptGlowInFrame = false; - thread.status = Thread.STATUS_DONE; -}; + /** + * Retire a thread in the middle, without considering further blocks. + * @param {!Thread} thread Thread object to retire. + */ + retireThread (thread) { + thread.stack = []; + thread.stackFrame = []; + thread.requestScriptGlowInFrame = false; + thread.status = Thread.STATUS_DONE; + } +} module.exports = Sequencer; diff --git a/src/engine/target.js b/src/engine/target.js index d5a59b47..9861c455 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -1,7 +1,10 @@ -var Blocks = require('./blocks'); -var Variable = require('../engine/variable'); -var List = require('../engine/list'); -var uid = require('../util/uid'); +const EventEmitter = require('events'); + +const Blocks = require('./blocks'); +const Variable = require('../engine/variable'); +const uid = require('../util/uid'); +const {Map} = require('immutable'); +const log = require('../util/log'); /** * @fileoverview @@ -9,108 +12,259 @@ var uid = require('../util/uid'); * Examples include sprites/clones or potentially physical-world devices. */ -/** - * @param {?Blocks} blocks Blocks instance for the blocks owned by this target. - * @constructor - */ -function Target (blocks) { - if (!blocks) { - blocks = new Blocks(this); +class Target extends EventEmitter { + + /** + * @param {Runtime} runtime Reference to the runtime. + * @param {?Blocks} blocks Blocks instance for the blocks owned by this target. + * @constructor + */ + constructor (runtime, blocks) { + super(); + + if (!blocks) { + blocks = new Blocks(); + } + + /** + * Reference to the runtime. + * @type {Runtime} + */ + this.runtime = runtime; + /** + * A unique ID for this target. + * @type {string} + */ + this.id = uid(); + /** + * Blocks run as code for this target. + * @type {!Blocks} + */ + this.blocks = blocks; + /** + * Dictionary of variables and their values for this target. + * Key is the variable name. + * @type {Object.} + */ + this.variables = {}; + /** + * Dictionary of lists and their contents for this target. + * Key is the list name. + * @type {Object.} + */ + this.lists = {}; + /** + * Dictionary of custom state for this target. + * This can be used to store target-specific custom state for blocks which need it. + * TODO: do we want to persist this in SB3 files? + * @type {Object.} + */ + this._customState = {}; } + /** - * A unique ID for this target. - * @type {string} + * Called when the project receives a "green flag." + * @abstract */ - this.id = uid(); + onGreenFlag () {} + /** - * Blocks run as code for this target. - * @type {!Blocks} + * Return a human-readable name for this target. + * Target implementations should override this. + * @abstract + * @returns {string} Human-readable name for the target. */ - this.blocks = blocks; + getName () { + return this.id; + } + /** - * Dictionary of variables and their values for this target. - * Key is the variable name. - * @type {Object.} + * Look up a variable object, and create it if one doesn't exist. + * @param {string} id Id of the variable. + * @param {string} name Name of the variable. + * @return {!Variable} Variable object. */ - this.variables = {}; + lookupOrCreateVariable (id, name) { + const variable = this.lookupVariableById(id); + if (variable) return variable; + // No variable with this name exists - create it locally. + const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false); + this.variables[id] = newVariable; + return newVariable; + } + /** - * Dictionary of lists and their contents for this target. - * Key is the list name. - * @type {Object.} + * Look up a broadcast message object with the given id and return it + * if it exists. + * @param {string} id Id of the variable. + * @param {string} name Name of the variable. + * @return {?Variable} Variable object. */ - this.lists = {}; + lookupBroadcastMsg (id, name) { + let broadcastMsg; + if (id) { + broadcastMsg = this.lookupVariableById(id); + } else if (name) { + broadcastMsg = this.lookupBroadcastByInputValue(name); + } else { + log.error('Cannot find broadcast message if neither id nor name are provided.'); + } + if (broadcastMsg) { + if (name && (broadcastMsg.name.toLowerCase() !== name.toLowerCase())) { + log.error(`Found broadcast message with id: ${id}, but` + + `its name, ${broadcastMsg.name} did not match expected name ${name}.`); + } + if (broadcastMsg.type !== Variable.BROADCAST_MESSAGE_TYPE) { + log.error(`Found variable with id: ${id}, but its type ${broadcastMsg.type}` + + `did not match expected type ${Variable.BROADCAST_MESSAGE_TYPE}`); + } + return broadcastMsg; + } + } + + /** + * Look up a broadcast message with the given name and return the variable + * if it exists. Does not create a new broadcast message variable if + * it doesn't exist. + * @param {string} name Name of the variable. + * @return {?Variable} Variable object. + */ + lookupBroadcastByInputValue (name) { + const vars = this.variables; + for (const propName in vars) { + if ((vars[propName].type === Variable.BROADCAST_MESSAGE_TYPE) && + (vars[propName].name.toLowerCase() === name.toLowerCase())) { + return vars[propName]; + } + } + } + + /** + * Look up a variable object. + * Search begins for local variables; then look for globals. + * @param {string} id Id of the variable. + * @param {string} name Name of the variable. + * @return {!Variable} Variable object. + */ + lookupVariableById (id) { + // If we have a local copy, return it. + if (this.variables.hasOwnProperty(id)) { + return this.variables[id]; + } + // If the stage has a global copy, return it. + if (this.runtime && !this.isStage) { + const stage = this.runtime.getTargetForStage(); + if (stage.variables.hasOwnProperty(id)) { + return stage.variables[id]; + } + } + } + + /** + * Look up a list object for this target, and create it if one doesn't exist. + * Search begins for local lists; then look for globals. + * @param {!string} id Id of the list. + * @param {!string} name Name of the list. + * @return {!Varible} Variable object representing the found/created list. + */ + lookupOrCreateList (id, name) { + const list = this.lookupVariableById(id); + if (list) return list; + // No variable with this name exists - create it locally. + const newList = new Variable(id, name, Variable.LIST_TYPE, false); + this.variables[id] = newList; + return newList; + } + + /** + * Creates a variable with the given id and name and adds it to the + * dictionary of variables. + * @param {string} id Id of variable + * @param {string} name Name of variable. + * @param {string} type Type of variable, '', 'broadcast_msg', or 'list' + */ + createVariable (id, name, type) { + if (!this.variables.hasOwnProperty(id)) { + const newVariable = new Variable(id, name, type, false); + this.variables[id] = newVariable; + } + } + + /** + * Renames the variable with the given id to newName. + * @param {string} id Id of renamed variable. + * @param {string} newName New name for the variable. + */ + renameVariable (id, newName) { + if (this.variables.hasOwnProperty(id)) { + const variable = this.variables[id]; + if (variable.id === id) { + variable.name = newName; + + if (this.runtime) { + const blocks = this.runtime.monitorBlocks; + blocks.changeBlock({ + id: id, + element: 'field', + name: 'VARIABLE', + value: id + }, this.runtime); + this.runtime.requestUpdateMonitor(Map({ + id: id, + params: blocks._getBlockParams(blocks.getBlock(variable.id)) + })); + } + + } + } + } + + /** + * Removes the variable with the given id from the dictionary of variables. + * @param {string} id Id of renamed variable. + */ + deleteVariable (id) { + if (this.variables.hasOwnProperty(id)) { + delete this.variables[id]; + if (this.runtime) { + this.runtime.monitorBlocks.deleteBlock(id); + this.runtime.requestRemoveMonitor(id); + } + } + } + + /** + * Post/edit sprite info. + * @param {object} data An object with sprite info data to set. + * @abstract + */ + postSpriteInfo () {} + + /** + * Retrieve custom state associated with this target and the provided state ID. + * @param {string} stateId - specify which piece of state to retrieve. + * @returns {*} the associated state, if any was found. + */ + getCustomState (stateId) { + return this._customState[stateId]; + } + + /** + * Store custom state associated with this target and the provided state ID. + * @param {string} stateId - specify which piece of state to store on this target. + * @param {*} newValue - the state value to store. + */ + setCustomState (stateId, newValue) { + this._customState[stateId] = newValue; + } + + /** + * Call to destroy a target. + * @abstract + */ + dispose () { + this._customState = {}; + } } -/** - * Called when the project receives a "green flag." - * @abstract - */ -Target.prototype.onGreenFlag = function () {}; - -/** - * Return a human-readable name for this target. - * Target implementations should override this. - * @abstract - * @returns {string} Human-readable name for the target. - */ -Target.prototype.getName = function () { - return this.id; -}; - -/** - * Look up a variable object, and create it if one doesn't exist. - * Search begins for local variables; then look for globals. - * @param {!string} name Name of the variable. - * @return {!Variable} Variable object. - */ -Target.prototype.lookupOrCreateVariable = function (name) { - // If we have a local copy, return it. - if (this.variables.hasOwnProperty(name)) { - return this.variables[name]; - } - // If the stage has a global copy, return it. - if (this.runtime && !this.isStage) { - var stage = this.runtime.getTargetForStage(); - if (stage.variables.hasOwnProperty(name)) { - return stage.variables[name]; - } - } - // No variable with this name exists - create it locally. - var newVariable = new Variable(name, 0, false); - this.variables[name] = newVariable; - return newVariable; -}; - -/** -* Look up a list object for this target, and create it if one doesn't exist. -* Search begins for local lists; then look for globals. -* @param {!string} name Name of the list. -* @return {!List} List object. - */ -Target.prototype.lookupOrCreateList = function (name) { - // If we have a local copy, return it. - if (this.lists.hasOwnProperty(name)) { - return this.lists[name]; - } - // If the stage has a global copy, return it. - if (this.runtime && !this.isStage) { - var stage = this.runtime.getTargetForStage(); - if (stage.lists.hasOwnProperty(name)) { - return stage.lists[name]; - } - } - // No list with this name exists - create it locally. - var newList = new List(name, []); - this.lists[name] = newList; - return newList; -}; - -/** - * Call to destroy a target. - * @abstract - */ -Target.prototype.dispose = function () { - -}; - module.exports = Target; diff --git a/src/engine/thread.js b/src/engine/thread.js index 9aeb559f..def1fc1e 100644 --- a/src/engine/thread.js +++ b/src/engine/thread.js @@ -3,235 +3,282 @@ * @param {?string} firstBlock First block to execute in the thread. * @constructor */ -function Thread (firstBlock) { - /** - * ID of top block of the thread - * @type {!string} - */ - this.topBlock = firstBlock; +class Thread { + constructor (firstBlock) { + /** + * ID of top block of the thread + * @type {!string} + */ + this.topBlock = firstBlock; + + /** + * Stack for the thread. When the sequencer enters a control structure, + * the block is pushed onto the stack so we know where to exit. + * @type {Array.} + */ + this.stack = []; + + /** + * Stack frames for the thread. Store metadata for the executing blocks. + * @type {Array.} + */ + this.stackFrames = []; + + /** + * Status of the thread, one of three states (below) + * @type {number} + */ + this.status = 0; /* Thread.STATUS_RUNNING */ + + /** + * Whether the thread is killed in the middle of execution. + * @type {boolean} + */ + this.isKilled = false; + + /** + * Target of this thread. + * @type {?Target} + */ + this.target = null; + + /** + * Whether the thread requests its script to glow during this frame. + * @type {boolean} + */ + this.requestScriptGlowInFrame = false; + + /** + * Which block ID should glow during this frame, if any. + * @type {?string} + */ + this.blockGlowInFrame = null; + + /** + * A timer for when the thread enters warp mode. + * Substitutes the sequencer's count toward WORK_TIME on a per-thread basis. + * @type {?Timer} + */ + this.warpTimer = null; + } /** - * Stack for the thread. When the sequencer enters a control structure, - * the block is pushed onto the stack so we know where to exit. - * @type {Array.} + * Thread status for initialized or running thread. + * This is the default state for a thread - execution should run normally, + * stepping from block to block. + * @const */ - this.stack = []; + static get STATUS_RUNNING () { + return 0; + } /** - * Stack frames for the thread. Store metadata for the executing blocks. - * @type {Array.} + * Threads are in this state when a primitive is waiting on a promise; + * execution is paused until the promise changes thread status. + * @const */ - this.stackFrames = []; + static get STATUS_PROMISE_WAIT () { + return 1; + } /** - * Status of the thread, one of three states (below) - * @type {number} + * Thread status for yield. + * @const */ - this.status = 0; /* Thread.STATUS_RUNNING */ + static get STATUS_YIELD () { + return 2; + } /** - * Target of this thread. - * @type {?Target} + * Thread status for a single-tick yield. This will be cleared when the + * thread is resumed. + * @const */ - this.target = null; + static get STATUS_YIELD_TICK () { + return 3; + } /** - * Whether the thread requests its script to glow during this frame. - * @type {boolean} + * Thread status for a finished/done thread. + * Thread is in this state when there are no more blocks to execute. + * @const */ - this.requestScriptGlowInFrame = false; + static get STATUS_DONE () { + return 4; + } /** - * Which block ID should glow during this frame, if any. - * @type {?string} + * Push stack and update stack frames appropriately. + * @param {string} blockId Block ID to push to stack. */ - this.blockGlowInFrame = null; + pushStack (blockId) { + this.stack.push(blockId); + // Push an empty stack frame, if we need one. + // Might not, if we just popped the stack. + if (this.stack.length > this.stackFrames.length) { + // Copy warp mode from any higher level. + let warpMode = false; + if (this.stackFrames.length > 0 && this.stackFrames[this.stackFrames.length - 1]) { + warpMode = this.stackFrames[this.stackFrames.length - 1].warpMode; + } + this.stackFrames.push({ + isLoop: false, // Whether this level of the stack is a loop. + warpMode: warpMode, // Whether this level is in warp mode. + reported: {}, // Collects reported input values. + waitingReporter: null, // Name of waiting reporter. + params: {}, // Procedure parameters. + executionContext: {} // A context passed to block implementations. + }); + } + } /** - * A timer for when the thread enters warp mode. - * Substitutes the sequencer's count toward WORK_TIME on a per-thread basis. - * @type {?Timer} + * Reset the stack frame for use by the next block. + * (avoids popping and re-pushing a new stack frame - keeps the warpmode the same + * @param {string} blockId Block ID to push to stack. */ - this.warpTimer = null; + reuseStackForNextBlock (blockId) { + this.stack[this.stack.length - 1] = blockId; + const frame = this.stackFrames[this.stackFrames.length - 1]; + frame.isLoop = false; + // frame.warpMode = warpMode; // warp mode stays the same when reusing the stack frame. + frame.reported = {}; + frame.waitingReporter = null; + frame.params = {}; + frame.executionContext = {}; + } + + /** + * Pop last block on the stack and its stack frame. + * @return {string} Block ID popped from the stack. + */ + popStack () { + this.stackFrames.pop(); + return this.stack.pop(); + } + + /** + * Pop back down the stack frame until we hit a procedure call or the stack frame is emptied + */ + stopThisScript () { + let blockID = this.peekStack(); + while (blockID !== null) { + const block = this.target.blocks.getBlock(blockID); + if (typeof block !== 'undefined' && block.opcode === 'procedures_call') { + break; + } + this.popStack(); + blockID = this.peekStack(); + } + + if (this.stack.length === 0) { + // Clean up! + this.requestScriptGlowInFrame = false; + this.status = Thread.STATUS_DONE; + } + } + + /** + * Get top stack item. + * @return {?string} Block ID on top of stack. + */ + peekStack () { + return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null; + } + + + /** + * Get top stack frame. + * @return {?object} Last stack frame stored on this thread. + */ + peekStackFrame () { + return this.stackFrames.length > 0 ? this.stackFrames[this.stackFrames.length - 1] : null; + } + + /** + * Get stack frame above the current top. + * @return {?object} Second to last stack frame stored on this thread. + */ + peekParentStackFrame () { + return this.stackFrames.length > 1 ? this.stackFrames[this.stackFrames.length - 2] : null; + } + + /** + * Push a reported value to the parent of the current stack frame. + * @param {*} value Reported value to push. + */ + pushReportedValue (value) { + const parentStackFrame = this.peekParentStackFrame(); + if (parentStackFrame) { + const waitingReporter = parentStackFrame.waitingReporter; + parentStackFrame.reported[waitingReporter] = value; + } + } + + /** + * Add a parameter to the stack frame. + * Use when calling a procedure with parameter values. + * @param {!string} paramName Name of parameter. + * @param {*} value Value to set for parameter. + */ + pushParam (paramName, value) { + const stackFrame = this.peekStackFrame(); + stackFrame.params[paramName] = value; + } + + /** + * Get a parameter at the lowest possible level of the stack. + * @param {!string} paramName Name of parameter. + * @return {*} value Value for parameter. + */ + getParam (paramName) { + for (let i = this.stackFrames.length - 1; i >= 0; i--) { + const frame = this.stackFrames[i]; + if (frame.params.hasOwnProperty(paramName)) { + return frame.params[paramName]; + } + } + return null; + } + + /** + * Whether the current execution of a thread is at the top of the stack. + * @return {boolean} True if execution is at top of the stack. + */ + atStackTop () { + return this.peekStack() === this.topBlock; + } + + + /** + * Switch the thread to the next block at the current level of the stack. + * For example, this is used in a standard sequence of blocks, + * where execution proceeds from one block to the next. + */ + goToNextBlock () { + const nextBlockId = this.target.blocks.getNextBlock(this.peekStack()); + this.reuseStackForNextBlock(nextBlockId); + } + + /** + * Attempt to determine whether a procedure call is recursive, + * by examining the stack. + * @param {!string} procedureCode Procedure code of procedure being called. + * @return {boolean} True if the call appears recursive. + */ + isRecursiveCall (procedureCode) { + let callCount = 5; // Max number of enclosing procedure calls to examine. + const sp = this.stack.length - 1; + for (let i = sp - 1; i >= 0; i--) { + const block = this.target.blocks.getBlock(this.stack[i]); + if (block.opcode === 'procedures_call' && + block.mutation.proccode === procedureCode) { + return true; + } + if (--callCount < 0) return false; + } + return false; + } } -/** - * Thread status for initialized or running thread. - * This is the default state for a thread - execution should run normally, - * stepping from block to block. - * @const - */ -Thread.STATUS_RUNNING = 0; - -/** - * Threads are in this state when a primitive is waiting on a promise; - * execution is paused until the promise changes thread status. - * @const - */ -Thread.STATUS_PROMISE_WAIT = 1; - -/** - * Thread status for yield. - * @const - */ -Thread.STATUS_YIELD = 2; - -/** - * Thread status for a single-tick yield. This will be cleared when the - * thread is resumed. - * @const - */ -Thread.STATUS_YIELD_TICK = 3; - -/** - * Thread status for a finished/done thread. - * Thread is in this state when there are no more blocks to execute. - * @const - */ -Thread.STATUS_DONE = 4; - -/** - * Push stack and update stack frames appropriately. - * @param {string} blockId Block ID to push to stack. - */ -Thread.prototype.pushStack = function (blockId) { - this.stack.push(blockId); - // Push an empty stack frame, if we need one. - // Might not, if we just popped the stack. - if (this.stack.length > this.stackFrames.length) { - // Copy warp mode from any higher level. - var warpMode = false; - if (this.stackFrames[this.stackFrames.length - 1]) { - warpMode = this.stackFrames[this.stackFrames.length - 1].warpMode; - } - this.stackFrames.push({ - isLoop: false, // Whether this level of the stack is a loop. - warpMode: warpMode, // Whether this level is in warp mode. - reported: {}, // Collects reported input values. - waitingReporter: null, // Name of waiting reporter. - params: {}, // Procedure parameters. - executionContext: {} // A context passed to block implementations. - }); - } -}; - -/** - * Pop last block on the stack and its stack frame. - * @return {string} Block ID popped from the stack. - */ -Thread.prototype.popStack = function () { - this.stackFrames.pop(); - return this.stack.pop(); -}; - -/** - * Get top stack item. - * @return {?string} Block ID on top of stack. - */ -Thread.prototype.peekStack = function () { - return this.stack[this.stack.length - 1]; -}; - - -/** - * Get top stack frame. - * @return {?Object} Last stack frame stored on this thread. - */ -Thread.prototype.peekStackFrame = function () { - return this.stackFrames[this.stackFrames.length - 1]; -}; - -/** - * Get stack frame above the current top. - * @return {?Object} Second to last stack frame stored on this thread. - */ -Thread.prototype.peekParentStackFrame = function () { - return this.stackFrames[this.stackFrames.length - 2]; -}; - -/** - * Push a reported value to the parent of the current stack frame. - * @param {*} value Reported value to push. - */ -Thread.prototype.pushReportedValue = function (value) { - var parentStackFrame = this.peekParentStackFrame(); - if (parentStackFrame) { - var waitingReporter = parentStackFrame.waitingReporter; - parentStackFrame.reported[waitingReporter] = value; - } -}; - -/** - * Add a parameter to the stack frame. - * Use when calling a procedure with parameter values. - * @param {!string} paramName Name of parameter. - * @param {*} value Value to set for parameter. - */ -Thread.prototype.pushParam = function (paramName, value) { - var stackFrame = this.peekStackFrame(); - stackFrame.params[paramName] = value; -}; - -/** - * Get a parameter at the lowest possible level of the stack. - * @param {!string} paramName Name of parameter. - * @return {*} value Value for parameter. - */ -Thread.prototype.getParam = function (paramName) { - for (var i = this.stackFrames.length - 1; i >= 0; i--) { - var frame = this.stackFrames[i]; - if (frame.params.hasOwnProperty(paramName)) { - return frame.params[paramName]; - } - } - return null; -}; - -/** - * Whether the current execution of a thread is at the top of the stack. - * @return {Boolean} True if execution is at top of the stack. - */ -Thread.prototype.atStackTop = function () { - return this.peekStack() === this.topBlock; -}; - - -/** - * Switch the thread to the next block at the current level of the stack. - * For example, this is used in a standard sequence of blocks, - * where execution proceeds from one block to the next. - */ -Thread.prototype.goToNextBlock = function () { - var nextBlockId = this.target.blocks.getNextBlock(this.peekStack()); - // Copy warp mode to next block. - var warpMode = this.peekStackFrame().warpMode; - // The current block is on the stack - pop it and push the next. - // Note that this could push `null` - that is handled by the sequencer. - this.popStack(); - this.pushStack(nextBlockId); - if (this.peekStackFrame()) { - this.peekStackFrame().warpMode = warpMode; - } -}; - -/** - * Attempt to determine whether a procedure call is recursive, - * by examining the stack. - * @param {!string} procedureCode Procedure code of procedure being called. - * @return {boolean} True if the call appears recursive. - */ -Thread.prototype.isRecursiveCall = function (procedureCode) { - var callCount = 5; // Max number of enclosing procedure calls to examine. - var sp = this.stack.length - 1; - for (var i = sp - 1; i >= 0; i--) { - var block = this.target.blocks.getBlock(this.stack[i]); - if (block.opcode == 'procedures_callnoreturn' && - block.mutation.proccode == procedureCode) { - return true; - } - if (--callCount < 0) return false; - } - return false; -}; - module.exports = Thread; diff --git a/src/engine/variable.js b/src/engine/variable.js index 4e5e5e6e..4aad9ffc 100644 --- a/src/engine/variable.js +++ b/src/engine/variable.js @@ -3,16 +3,65 @@ * Object representing a Scratch variable. */ -/** - * @param {!string} name Name of the variable. - * @param {(string|Number)} value Value of the variable. - * @param {boolean} isCloud Whether the variable is stored in the cloud. - * @constructor - */ -function Variable (name, value, isCloud) { - this.name = name; - this.value = value; - this.isCloud = isCloud; +const uid = require('../util/uid'); + +class Variable { + /** + * @param {string} id Id of the variable. + * @param {string} name Name of the variable. + * @param {string} type Type of the variable, one of '' or 'list' + * @param {boolean} isCloud Whether the variable is stored in the cloud. + * @constructor + */ + constructor (id, name, type, isCloud) { + this.id = id || uid(); + this.name = name; + this.type = type; + this.isCloud = isCloud; + switch (this.type) { + case Variable.SCALAR_TYPE: + this.value = 0; + break; + case Variable.LIST_TYPE: + this.value = []; + break; + case Variable.BROADCAST_MESSAGE_TYPE: + this.value = this.name; + break; + default: + throw new Error(`Invalid variable type: ${this.type}`); + } + } + + toXML () { + return `${this.name}`; + } + + /** + * Type representation for scalar variables. + * This is currently represented as '' + * for compatibility with blockly. + * @const {string} + */ + static get SCALAR_TYPE () { + return ''; + } + + /** + * Type representation for list variables. + * @const {string} + */ + static get LIST_TYPE () { + return 'list'; + } + + /** + * Type representation for list variables. + * @const {string} + */ + static get BROADCAST_MESSAGE_TYPE () { + return 'broadcast_msg'; + } } module.exports = Variable; diff --git a/src/extension-support/argument-type.js b/src/extension-support/argument-type.js new file mode 100644 index 00000000..0589e9fb --- /dev/null +++ b/src/extension-support/argument-type.js @@ -0,0 +1,9 @@ +const ArgumentType = { + ANGLE: 'angle', + BOOLEAN: 'Boolean', + COLOR: 'color', + NUMBER: 'number', + STRING: 'string' +}; + +module.exports = ArgumentType; diff --git a/src/extension-support/block-type.js b/src/extension-support/block-type.js new file mode 100644 index 00000000..587f7d59 --- /dev/null +++ b/src/extension-support/block-type.js @@ -0,0 +1,9 @@ +const BlockType = { + BOOLEAN: 'Boolean', + COMMAND: 'command', + CONDITIONAL: 'conditional', + HAT: 'hat', + REPORTER: 'reporter' +}; + +module.exports = BlockType; diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js new file mode 100644 index 00000000..3228f5c2 --- /dev/null +++ b/src/extension-support/extension-manager.js @@ -0,0 +1,280 @@ +const dispatch = require('../dispatch/central-dispatch'); +const log = require('../util/log'); + +const BlockType = require('./block-type'); + +// These extensions are currently built into the VM repository but should not be loaded at startup. +// TODO: move these out into a separate repository? +// TODO: change extension spec so that library info, including extension ID, can be collected through static methods +const Scratch3PenBlocks = require('../extensions/scratch3_pen'); +const Scratch3WeDo2Blocks = require('../extensions/scratch3_wedo2'); +const Scratch3MusicBlocks = require('../extensions/scratch3_music'); +const builtinExtensions = { + pen: Scratch3PenBlocks, + wedo2: Scratch3WeDo2Blocks, + music: Scratch3MusicBlocks +}; + +/** + * @typedef {object} ArgumentInfo - Information about an extension block argument + * @property {ArgumentType} type - the type of value this argument can take + * @property {*|undefined} default - the default value of this argument (default: blank) + */ + +/** + * @typedef {object} BlockInfo - Information about an extension block + * @property {string} opcode - the block opcode + * @property {string|object} text - the human-readable text on this block + * @property {BlockType|undefined} blockType - the type of block (default: BlockType.COMMAND) + * @property {int|undefined} branchCount - the number of branches this block controls, if conditional (default: 0) + * @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false) + * @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false) + * @property {object.|undefined} arguments - information about this block's arguments, if any + * @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode) + * @property {Array.|undefined} filter - the list of targets for which this block should appear (default: all) + * @property {Boolean|undefined} hideFromPalette - true if should not be appear in the palette. (default false) + */ + +/** + * @typedef {object} CategoryInfo - Information about a block category + * @property {string} id - the unique ID of this category + * @property {string} color1 - the primary color for this category, in '#rrggbb' format + * @property {string} color2 - the secondary color for this category, in '#rrggbb' format + * @property {string} color3 - the tertiary color for this category, in '#rrggbb' format + * @property {Array.} block - the blocks in this category + */ + +/** + * @typedef {object} PendingExtensionWorker - Information about an extension worker still initializing + * @property {string} extensionURL - the URL of the extension to be loaded by this worker + * @property {Function} resolve - function to call on successful worker startup + * @property {Function} reject - function to call on failed worker startup + */ + +class ExtensionManager { + constructor (runtime) { + /** + * The ID number to provide to the next extension worker. + * @type {int} + */ + this.nextExtensionWorker = 0; + + /** + * FIFO queue of extensions which have been requested but not yet loaded in a worker, + * along with promise resolution functions to call once the worker is ready or failed. + * + * @type {Array.} + */ + this.pendingExtensions = []; + + /** + * Map of worker ID to workers which have been allocated but have not yet finished initialization. + * @type {Array.} + */ + this.pendingWorkers = []; + + /** + * Set of loaded extension URLs/IDs (equivalent for built-in extensions). + * @type {Set.} + * @private + */ + this._loadedExtensions = new Map(); + + /** + * Keep a reference to the runtime so we can construct internal extension objects. + * TODO: remove this in favor of extensions accessing the runtime as a service. + * @type {Runtime} + */ + this.runtime = runtime; + + dispatch.setService('extensions', this).catch(e => { + log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`); + }); + } + + /** + * Check whether an extension is registered or is in the process of loading. This is intended to control loading or + * adding extensions so it may return `true` before the extension is ready to be used. Use the promise returned by + * `loadExtensionURL` if you need to wait until the extension is truly ready. + * @param {string} extensionID - the ID of the extension. + * @returns {boolean} - true if loaded, false otherwise. + */ + isExtensionLoaded (extensionID) { + return this._loadedExtensions.has(extensionID); + } + + /** + * Load an extension by URL or internal extension ID + * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension + * @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure + */ + loadExtensionURL (extensionURL) { + if (builtinExtensions.hasOwnProperty(extensionURL)) { + /** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ + if (this.isExtensionLoaded(extensionURL)) { + const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`; + log.warn(message); + return Promise.reject(new Error(message)); + } + + const extension = builtinExtensions[extensionURL]; + const extensionInstance = new extension(this.runtime); + return this._registerInternalExtension(extensionInstance).then(serviceName => { + this._loadedExtensions.set(extensionURL, serviceName); + }); + } + + return new Promise((resolve, reject) => { + // If we `require` this at the global level it breaks non-webpack targets, including tests + const ExtensionWorker = require('worker-loader?name=extension-worker.js!./extension-worker'); + + this.pendingExtensions.push({extensionURL, resolve, reject}); + dispatch.addWorker(new ExtensionWorker()); + }); + } + + /** + * regenerate blockinfo for any loaded extensions + */ + refreshBlocks () { + this._loadedExtensions.forEach(serviceName => { + dispatch.call(serviceName, 'getInfo') + .then(info => { + dispatch.call('runtime', '_refreshExtensionPrimitives', info); + }) + .catch(e => { + log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`); + }); + }); + } + + allocateWorker () { + const id = this.nextExtensionWorker++; + const workerInfo = this.pendingExtensions.shift(); + this.pendingWorkers[id] = workerInfo; + return [id, workerInfo.extensionURL]; + } + + /** + * Collect extension metadata from the specified service and begin the extension registration process. + * @param {string} serviceName - the name of the service hosting the extension. + */ + registerExtensionService (serviceName) { + dispatch.call(serviceName, 'getInfo').then(info => { + this._registerExtensionInfo(serviceName, info); + }); + } + + /** + * Called by an extension worker to indicate that the worker has finished initialization. + * @param {int} id - the worker ID. + * @param {*?} e - the error encountered during initialization, if any. + */ + onWorkerInit (id, e) { + const workerInfo = this.pendingWorkers[id]; + delete this.pendingWorkers[id]; + if (e) { + workerInfo.reject(e); + } else { + workerInfo.resolve(id); + } + } + + /** + * Register an internal (non-Worker) extension object + * @param {object} extensionObject - the extension object to register + * @returns {Promise} resolved once the extension is fully registered or rejected on failure + */ + _registerInternalExtension (extensionObject) { + const extensionInfo = extensionObject.getInfo(); + const fakeWorkerId = this.nextExtensionWorker++; + const serviceName = `extension.${fakeWorkerId}.${extensionInfo.id}`; + return dispatch.setService(serviceName, extensionObject) + .then(() => { + dispatch.call('extensions', 'registerExtensionService', serviceName); + return serviceName; + }); + } + + /** + * Sanitize extension info then register its primitives with the VM. + * @param {string} serviceName - the name of the service hosting the extension + * @param {ExtensionInfo} extensionInfo - the extension's metadata + * @private + */ + _registerExtensionInfo (serviceName, extensionInfo) { + extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo); + dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => { + log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`); + }); + } + + /** + * Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML. + * @param {string} text - the text to be sanitized + * @returns {string} - the sanitized text + * @private + */ + _sanitizeID (text) { + return text.toString().replace(/[<"&]/, '_'); + } + + /** + * Apply minor cleanup and defaults for optional extension fields. + * TODO: make the ID unique in cases where two copies of the same extension are loaded. + * @param {string} serviceName - the name of the service hosting this extension block + * @param {ExtensionInfo} extensionInfo - the extension info to be sanitized + * @returns {ExtensionInfo} - a new extension info object with cleaned-up values + * @private + */ + _prepareExtensionInfo (serviceName, extensionInfo) { + extensionInfo = Object.assign({}, extensionInfo); + extensionInfo.id = this._sanitizeID(extensionInfo.id); + extensionInfo.name = extensionInfo.name || extensionInfo.id; + extensionInfo.blocks = extensionInfo.blocks || []; + extensionInfo.targetTypes = extensionInfo.targetTypes || []; + extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => { + try { + result.push(this._prepareBlockInfo(serviceName, blockInfo)); + } catch (e) { + // TODO: more meaningful error reporting + log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`); + } + return result; + }, []); + return extensionInfo; + } + + /** + * Apply defaults for optional block fields. + * @param {string} serviceName - the name of the service hosting this extension block + * @param {BlockInfo} blockInfo - the block info from the extension + * @returns {BlockInfo} - a new block info object which has values for all relevant optional fields. + * @private + */ + _prepareBlockInfo (serviceName, blockInfo) { + blockInfo = Object.assign({}, { + blockType: BlockType.COMMAND, + terminal: false, + blockAllThreads: false, + arguments: {} + }, blockInfo); + blockInfo.opcode = this._sanitizeID(blockInfo.opcode); + blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode; + blockInfo.text = blockInfo.text || blockInfo.opcode; + + /** + * This is only here because the VM performs poorly when blocks return promises. + * @TODO make it possible for the VM to resolve a promise and continue during the same frame. + */ + if (dispatch._isRemoteService(serviceName)) { + blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func); + } else { + const serviceObject = dispatch.services[serviceName]; + blockInfo.func = serviceObject[blockInfo.func].bind(serviceObject); + } + return blockInfo; + } +} + +module.exports = ExtensionManager; diff --git a/src/extension-support/extension-worker.js b/src/extension-support/extension-worker.js new file mode 100644 index 00000000..02406e08 --- /dev/null +++ b/src/extension-support/extension-worker.js @@ -0,0 +1,57 @@ +/* eslint-env worker */ + +const ArgumentType = require('../extension-support/argument-type'); +const BlockType = require('../extension-support/block-type'); +const dispatch = require('../dispatch/worker-dispatch'); + +class ExtensionWorker { + constructor () { + this.nextExtensionId = 0; + + this.initialRegistrations = []; + + dispatch.waitForConnection.then(() => { + dispatch.call('extensions', 'allocateWorker').then(x => { + const [id, extension] = x; + this.workerId = id; + + try { + importScripts(extension); + + const initialRegistrations = this.initialRegistrations; + this.initialRegistrations = null; + + Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id)); + } catch (e) { + dispatch.call('extensions', 'onWorkerInit', id, e); + } + }); + }); + + this.extensions = []; + } + + register (extensionObject) { + const extensionId = this.nextExtensionId++; + this.extensions.push(extensionObject); + const serviceName = `extension.${this.workerId}.${extensionId}`; + const promise = dispatch.setService(serviceName, extensionObject) + .then(() => dispatch.call('extensions', 'registerExtensionService', serviceName)); + if (this.initialRegistrations) { + this.initialRegistrations.push(promise); + } + return promise; + } +} + +global.Scratch = global.Scratch || {}; +global.Scratch.ArgumentType = ArgumentType; +global.Scratch.BlockType = BlockType; + +/** + * Expose only specific parts of the worker to extensions. + */ +const extensionWorker = new ExtensionWorker(); +global.Scratch.extensions = { + register: extensionWorker.register.bind(extensionWorker) +}; diff --git a/src/extensions/scratch3_music/assets/drums/1-snare.mp3 b/src/extensions/scratch3_music/assets/drums/1-snare.mp3 new file mode 100644 index 00000000..6cbd0c8c Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/1-snare.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/10-wood-block.mp3 b/src/extensions/scratch3_music/assets/drums/10-wood-block.mp3 new file mode 100644 index 00000000..9c551b36 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/10-wood-block.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/11-cowbell.mp3 b/src/extensions/scratch3_music/assets/drums/11-cowbell.mp3 new file mode 100644 index 00000000..4f623cd1 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/11-cowbell.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/12-triangle.mp3 b/src/extensions/scratch3_music/assets/drums/12-triangle.mp3 new file mode 100644 index 00000000..0987759c Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/12-triangle.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/13-bongo.mp3 b/src/extensions/scratch3_music/assets/drums/13-bongo.mp3 new file mode 100644 index 00000000..5faf0793 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/13-bongo.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/14-conga.mp3 b/src/extensions/scratch3_music/assets/drums/14-conga.mp3 new file mode 100644 index 00000000..585feca6 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/14-conga.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/15-cabasa.mp3 b/src/extensions/scratch3_music/assets/drums/15-cabasa.mp3 new file mode 100644 index 00000000..4fce0617 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/15-cabasa.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/16-guiro.mp3 b/src/extensions/scratch3_music/assets/drums/16-guiro.mp3 new file mode 100644 index 00000000..882feaaa Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/16-guiro.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/17-vibraslap.mp3 b/src/extensions/scratch3_music/assets/drums/17-vibraslap.mp3 new file mode 100644 index 00000000..faf7315a Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/17-vibraslap.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/18-cuica.mp3 b/src/extensions/scratch3_music/assets/drums/18-cuica.mp3 new file mode 100644 index 00000000..1970b60c Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/18-cuica.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/2-bass-drum.mp3 b/src/extensions/scratch3_music/assets/drums/2-bass-drum.mp3 new file mode 100644 index 00000000..bc253327 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/2-bass-drum.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/3-side-stick.mp3 b/src/extensions/scratch3_music/assets/drums/3-side-stick.mp3 new file mode 100644 index 00000000..c7757c93 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/3-side-stick.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/4-crash-cymbal.mp3 b/src/extensions/scratch3_music/assets/drums/4-crash-cymbal.mp3 new file mode 100644 index 00000000..bf9c6647 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/4-crash-cymbal.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/5-open-hi-hat.mp3 b/src/extensions/scratch3_music/assets/drums/5-open-hi-hat.mp3 new file mode 100644 index 00000000..9e9e62b5 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/5-open-hi-hat.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/6-closed-hi-hat.mp3 b/src/extensions/scratch3_music/assets/drums/6-closed-hi-hat.mp3 new file mode 100644 index 00000000..842228e3 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/6-closed-hi-hat.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/7-tambourine.mp3 b/src/extensions/scratch3_music/assets/drums/7-tambourine.mp3 new file mode 100644 index 00000000..f0ea66a5 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/7-tambourine.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/8-hand-clap.mp3 b/src/extensions/scratch3_music/assets/drums/8-hand-clap.mp3 new file mode 100644 index 00000000..a5b55f52 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/8-hand-clap.mp3 differ diff --git a/src/extensions/scratch3_music/assets/drums/9-claves.mp3 b/src/extensions/scratch3_music/assets/drums/9-claves.mp3 new file mode 100644 index 00000000..75b76533 Binary files /dev/null and b/src/extensions/scratch3_music/assets/drums/9-claves.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/108.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/108.mp3 new file mode 100644 index 00000000..a7c05073 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/108.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/24.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/24.mp3 new file mode 100644 index 00000000..3ee19ed1 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/24.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/36.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/36.mp3 new file mode 100644 index 00000000..b680da22 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/48.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/48.mp3 new file mode 100644 index 00000000..cf099153 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/60.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/60.mp3 new file mode 100644 index 00000000..adb7052b Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/72.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/72.mp3 new file mode 100644 index 00000000..27451fc6 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/72.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/84.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/84.mp3 new file mode 100644 index 00000000..f32a1da7 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/84.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/1-piano/96.mp3 b/src/extensions/scratch3_music/assets/instruments/1-piano/96.mp3 new file mode 100644 index 00000000..4efc40bf Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/1-piano/96.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/10-clarinet/48.mp3 b/src/extensions/scratch3_music/assets/instruments/10-clarinet/48.mp3 new file mode 100644 index 00000000..c7d46dbd Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/10-clarinet/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/10-clarinet/60.mp3 b/src/extensions/scratch3_music/assets/instruments/10-clarinet/60.mp3 new file mode 100644 index 00000000..12460dfc Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/10-clarinet/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/11-saxophone/36.mp3 b/src/extensions/scratch3_music/assets/instruments/11-saxophone/36.mp3 new file mode 100755 index 00000000..472afa8f Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/11-saxophone/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/11-saxophone/60.mp3 b/src/extensions/scratch3_music/assets/instruments/11-saxophone/60.mp3 new file mode 100755 index 00000000..92da76ce Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/11-saxophone/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/11-saxophone/84.mp3 b/src/extensions/scratch3_music/assets/instruments/11-saxophone/84.mp3 new file mode 100755 index 00000000..7a961d0d Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/11-saxophone/84.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/12-flute/60.mp3 b/src/extensions/scratch3_music/assets/instruments/12-flute/60.mp3 new file mode 100644 index 00000000..0189b3ef Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/12-flute/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/12-flute/72.mp3 b/src/extensions/scratch3_music/assets/instruments/12-flute/72.mp3 new file mode 100644 index 00000000..7b57139b Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/12-flute/72.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/60.mp3 b/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/60.mp3 new file mode 100644 index 00000000..c898b844 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/72.mp3 b/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/72.mp3 new file mode 100644 index 00000000..6a7a9e99 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/13-wooden-flute/72.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/14-bassoon/36.mp3 b/src/extensions/scratch3_music/assets/instruments/14-bassoon/36.mp3 new file mode 100644 index 00000000..d8ff9f5c Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/14-bassoon/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/14-bassoon/48.mp3 b/src/extensions/scratch3_music/assets/instruments/14-bassoon/48.mp3 new file mode 100644 index 00000000..5592bc64 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/14-bassoon/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/14-bassoon/60.mp3 b/src/extensions/scratch3_music/assets/instruments/14-bassoon/60.mp3 new file mode 100644 index 00000000..640f3de2 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/14-bassoon/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/15-choir/48.mp3 b/src/extensions/scratch3_music/assets/instruments/15-choir/48.mp3 new file mode 100644 index 00000000..0b87ef6e Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/15-choir/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/15-choir/60.mp3 b/src/extensions/scratch3_music/assets/instruments/15-choir/60.mp3 new file mode 100644 index 00000000..48924795 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/15-choir/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/15-choir/72.mp3 b/src/extensions/scratch3_music/assets/instruments/15-choir/72.mp3 new file mode 100644 index 00000000..0e3074f4 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/15-choir/72.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/16-vibraphone/60.mp3 b/src/extensions/scratch3_music/assets/instruments/16-vibraphone/60.mp3 new file mode 100644 index 00000000..988f8231 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/16-vibraphone/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/16-vibraphone/72.mp3 b/src/extensions/scratch3_music/assets/instruments/16-vibraphone/72.mp3 new file mode 100644 index 00000000..ebdd5e0e Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/16-vibraphone/72.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/17-music-box/60.mp3 b/src/extensions/scratch3_music/assets/instruments/17-music-box/60.mp3 new file mode 100644 index 00000000..169df684 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/17-music-box/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/18-steel-drum/60.mp3 b/src/extensions/scratch3_music/assets/instruments/18-steel-drum/60.mp3 new file mode 100644 index 00000000..e7b8585b Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/18-steel-drum/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/19-marimba/60.mp3 b/src/extensions/scratch3_music/assets/instruments/19-marimba/60.mp3 new file mode 100644 index 00000000..54b7df80 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/19-marimba/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/2-electric-piano/60.mp3 b/src/extensions/scratch3_music/assets/instruments/2-electric-piano/60.mp3 new file mode 100644 index 00000000..d8d1abc1 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/2-electric-piano/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/20-synth-lead/60.mp3 b/src/extensions/scratch3_music/assets/instruments/20-synth-lead/60.mp3 new file mode 100644 index 00000000..0c90c3e3 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/20-synth-lead/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/21-synth-pad/60.mp3 b/src/extensions/scratch3_music/assets/instruments/21-synth-pad/60.mp3 new file mode 100644 index 00000000..93ff1d94 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/21-synth-pad/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/3-organ/60.mp3 b/src/extensions/scratch3_music/assets/instruments/3-organ/60.mp3 new file mode 100644 index 00000000..a329b732 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/3-organ/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/4-guitar/60.mp3 b/src/extensions/scratch3_music/assets/instruments/4-guitar/60.mp3 new file mode 100644 index 00000000..064672bb Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/4-guitar/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/5-electric-guitar/60.mp3 b/src/extensions/scratch3_music/assets/instruments/5-electric-guitar/60.mp3 new file mode 100755 index 00000000..f8558dee Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/5-electric-guitar/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/6-bass/36.mp3 b/src/extensions/scratch3_music/assets/instruments/6-bass/36.mp3 new file mode 100644 index 00000000..d967d830 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/6-bass/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/6-bass/48.mp3 b/src/extensions/scratch3_music/assets/instruments/6-bass/48.mp3 new file mode 100644 index 00000000..5c8ce566 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/6-bass/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/7-pizzicato/60.mp3 b/src/extensions/scratch3_music/assets/instruments/7-pizzicato/60.mp3 new file mode 100644 index 00000000..589c6578 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/7-pizzicato/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/8-cello/36.mp3 b/src/extensions/scratch3_music/assets/instruments/8-cello/36.mp3 new file mode 100644 index 00000000..c337dedf Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/8-cello/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/8-cello/48.mp3 b/src/extensions/scratch3_music/assets/instruments/8-cello/48.mp3 new file mode 100644 index 00000000..c4b556da Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/8-cello/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/8-cello/60.mp3 b/src/extensions/scratch3_music/assets/instruments/8-cello/60.mp3 new file mode 100644 index 00000000..28aeded5 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/8-cello/60.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/9-trombone/36.mp3 b/src/extensions/scratch3_music/assets/instruments/9-trombone/36.mp3 new file mode 100644 index 00000000..95c289ba Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/9-trombone/36.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/9-trombone/48.mp3 b/src/extensions/scratch3_music/assets/instruments/9-trombone/48.mp3 new file mode 100644 index 00000000..2eee89d3 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/9-trombone/48.mp3 differ diff --git a/src/extensions/scratch3_music/assets/instruments/9-trombone/60.mp3 b/src/extensions/scratch3_music/assets/instruments/9-trombone/60.mp3 new file mode 100644 index 00000000..4f00cf11 Binary files /dev/null and b/src/extensions/scratch3_music/assets/instruments/9-trombone/60.mp3 differ diff --git a/src/extensions/scratch3_music/index.js b/src/extensions/scratch3_music/index.js new file mode 100644 index 00000000..6d391bb9 --- /dev/null +++ b/src/extensions/scratch3_music/index.js @@ -0,0 +1,832 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const Clone = require('../../util/clone'); +const Cast = require('../../util/cast'); +const MathUtil = require('../../util/math-util'); +const Timer = require('../../util/timer'); + +/** + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPm11c2ljLWJsb2NrLWljb248L3RpdGxlPjxkZWZzPjxwYXRoIGQ9Ik0zMi4xOCAyNS44NzRDMzIuNjM2IDI4LjE1NyAzMC41MTIgMzAgMjcuNDMzIDMwYy0zLjA3IDAtNS45MjMtMS44NDMtNi4zNzItNC4xMjYtLjQ1OC0yLjI4NSAxLjY2NS00LjEzNiA0Ljc0My00LjEzNi42NDcgMCAxLjI4My4wODQgMS44OS4yMzQuMzM4LjA4Ni42MzcuMTguOTM4LjMwMi44Ny0uMDItLjEwNC0yLjI5NC0xLjgzNS0xMi4yMy0yLjEzNC0xMi4zMDIgMy4wNi0xLjg3IDguNzY4LTIuNzUyIDUuNzA4LS44ODUuMDc2IDQuODItMy42NSAzLjg0NC0zLjcyNC0uOTg3LTQuNjUtNy4xNTMuMjYzIDE0LjczOHptLTE2Ljk5OCA1Ljk5QzE1LjYzIDM0LjE0OCAxMy41MDcgMzYgMTAuNDQgMzZjLTMuMDcgMC01LjkyMi0xLjg1Mi02LjM4LTQuMTM2LS40NDgtMi4yODQgMS42NzQtNC4xMzUgNC43NS00LjEzNSAxLjAwMyAwIDEuOTc1LjE5NiAyLjg1NS41NDMuODIyLS4wNTUtLjE1LTIuMzc3LTEuODYyLTEyLjIyOC0yLjEzMy0xMi4zMDMgMy4wNi0xLjg3IDguNzY0LTIuNzUzIDUuNzA2LS44OTQuMDc2IDQuODItMy42NDggMy44MzQtMy43MjQtLjk4Ny00LjY1LTcuMTUyLjI2MiAxNC43Mzh6IiBpZD0iYSIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48cGF0aCBzdHJva2Utb3BhY2l0eT0iLjEiIHN0cm9rZT0iIzAwMCIgZD0iTTI4LjQ1NiAyMS42NzVjLS4wMS0uMzEyLS4wODctLjgyNS0uMjU2LTEuNzAyLS4wOTYtLjQ5NS0uNjEyLTMuMDIyLS43NTMtMy43My0uMzk1LTEuOTgtLjc2LTMuOTItMS4xNDItNi4xMTMtLjczMi00LjIyMy0uNjkzLTYuMDUuMzQ0LTYuNTI3LjUtLjIzIDEuMDYtLjA4IDEuODQuMzUuNDE0LjIyNyAyLjE4MiAxLjM2NSAyLjA3IDEuMjk2IDEuOTk0IDEuMjQyIDMuNDY0IDEuNzc0IDQuOTMgMS41NDggMS41MjYtLjIzNyAyLjUwNC0uMDYgMi44NzYuNjE4LjM0OC42MzUuMDE1IDEuNDE2LS43MyAyLjE4LTEuNDcyIDEuNTE2LTMuOTc1IDIuNTE0LTUuODQ4IDIuMDIzLS44MjItLjIyLTEuMjM4LS40NjUtMi4zOC0xLjI2N2wtLjA5NS0uMDY2Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMy4yOTQgMS4zMzYgMi4wOCA5LjE4NyAyLjYzNyAxMS42NzRsLjAwMi4wMTJjLjUyOCAyLjYzNy0xLjg3MyA0LjcyNC01LjIzNiA0LjcyNC0zLjI5IDAtNi4zNjMtMS45ODgtNi44NjItNC41MjgtLjUzLTIuNjQgMS44NzMtNC43MzQgNS4yMzMtNC43MzQuNjcyIDAgMS4zNDcuMDg1IDIuMDE0LjI1LjIyNy4wNTcuNDM2LjExOC42MzYuMTg3em0tMTYuOTk2IDUuOTljLS4wMS0uMzE4LS4wOS0uODM4LS4yNjYtMS43MzctLjA5LS40Ni0uNTk1LTIuOTM3LS43NTMtMy43MjctLjM5LTEuOTYtLjc1LTMuODktMS4xMy02LjA3LS43MzItNC4yMjMtLjY5Mi02LjA1LjM0NC02LjUyNi41MDItLjIzIDEuMDYtLjA4MiAxLjg0LjM1LjQxNS4yMjcgMi4xODIgMS4zNjQgMi4wNyAxLjI5NSAxLjk5MyAxLjI0MiAzLjQ2MiAxLjc3NCA0LjkyNiAxLjU0OCAxLjUyNS0uMjQgMi41MDQtLjA2NCAyLjg3Ni42MTQuMzQ4LjYzNS4wMTUgMS40MTUtLjcyOCAyLjE4LTEuNDc0IDEuNTE3LTMuOTc3IDIuNTEzLTUuODQ3IDIuMDE3LS44Mi0uMjItMS4yMzYtLjQ2NC0yLjM3OC0xLjI2N2wtLjA5NS0uMDY1Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMi4yOTQgMS4zMzcgMi4wNzggOS4xOSAyLjYzNiAxMS42NzVsLjAwMy4wMTNjLjUxNyAyLjYzOC0xLjg4NCA0LjczMi01LjIzNCA0LjczMi0zLjI4NyAwLTYuMzYtMS45OTMtNi44Ny00LjU0LS41Mi0yLjY0IDEuODg0LTQuNzMgNS4yNC00LjczLjkwNSAwIDEuODAzLjE1IDIuNjUuNDM2eiIvPjwvZz48L3N2Zz4='; + +/** + * Icon svg to be displayed in the category menu, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const menuIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2LjA5IDEyLjkzN2MuMjI4IDEuMTQxLS44MzMgMi4wNjMtMi4zNzMgMi4wNjMtMS41MzUgMC0yLjk2Mi0uOTIyLTMuMTg2LTIuMDYzLS4yMy0xLjE0Mi44MzMtMi4wNjggMi4zNzItMi4wNjguMzIzIDAgLjY0MS4wNDIuOTQ1LjExN2EzLjUgMy41IDAgMCAxIC40NjguMTUxYy40MzUtLjAxLS4wNTItMS4xNDctLjkxNy02LjExNC0xLjA2Ny02LjE1MiAxLjUzLS45MzUgNC4zODQtMS4zNzcgMi44NTQtLjQ0Mi4wMzggMi40MS0xLjgyNSAxLjkyMi0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc3LjEzMiA3LjM3ek03LjQ2IDguNTYzYy0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc2LjEzIDcuMzdDNy44MTYgMTcuMDczIDYuNzU0IDE4IDUuMjIgMThjLTEuNTM1IDAtMi45NjEtLjkyNi0zLjE5LTIuMDY4LS4yMjQtMS4xNDIuODM3LTIuMDY3IDIuMzc1LTIuMDY3LjUwMSAwIC45ODcuMDk4IDEuNDI3LjI3Mi40MTItLjAyOC0uMDc0LTEuMTg5LS45My02LjExNEMzLjgzNCAxLjg3IDYuNDMgNy4wODcgOS4yODIgNi42NDZjMi44NTQtLjQ0Ny4wMzggMi40MS0xLjgyMyAxLjkxN3oiIGZpbGw9IiM1NzVFNzUiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=='; + +/** + * Class for the music-related blocks in Scratch 3.0 + * @param {Runtime} runtime - the runtime instantiating this block package. + * @constructor + */ +class Scratch3MusicBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + /** + * The current tempo in beats per minute. The tempo is a global property of the project, + * not a property of each sprite, so it is not stored in the MusicState object. + * @type {number} + */ + this.tempo = 60; + + /** + * The number of drum and instrument sounds currently being played simultaneously. + * @type {number} + * @private + */ + this._concurrencyCounter = 0; + + /** + * An array of audio buffers, one for each drum sound. + * @type {Array} + * @private + */ + this._drumBuffers = []; + + /** + * An array of arrays of audio buffers. Each instrument has one or more audio buffers. + * @type {Array[]} + * @private + */ + this._instrumentBufferArrays = []; + + /** + * An array of audio bufferSourceNodes. Each time you play an instrument or drum sound, + * a bufferSourceNode is created. We keep references to them to make sure their onended + * events can fire. + * @type {Array} + * @private + */ + this._bufferSources = []; + + this._loadAllSounds(); + } + + /** + * Download and decode the full set of drum and instrument sounds, and + * store the audio buffers in arrays. + */ + _loadAllSounds () { + const loadingPromises = []; + this.DRUM_INFO.forEach((drumInfo, index) => { + const fileName = `drums/${drumInfo.fileName}`; + const promise = this._loadSound(fileName, index, this._drumBuffers); + loadingPromises.push(promise); + }); + this.INSTRUMENT_INFO.forEach((instrumentInfo, instrumentIndex) => { + this._instrumentBufferArrays[instrumentIndex] = []; + instrumentInfo.samples.forEach((sample, noteIndex) => { + const fileName = `instruments/${instrumentInfo.dirName}/${sample}`; + const promise = this._loadSound(fileName, noteIndex, this._instrumentBufferArrays[instrumentIndex]); + loadingPromises.push(promise); + }); + }); + Promise.all(loadingPromises).then(() => { + // @TODO: Update the extension status indicator. + }); + } + + /** + * Download and decode a sound, and store the buffer in an array. + * @param {string} fileName - the audio file name. + * @param {number} index - the index at which to store the audio buffer. + * @param {array} bufferArray - the array of buffers in which to store it. + * @return {Promise} - a promise which will resolve once the sound has loaded. + */ + _loadSound (fileName, index, bufferArray) { + if (!this.runtime.storage) return; + if (!this.runtime.audioEngine) return; + if (!this.runtime.audioEngine.audioContext) return; + return this.runtime.storage.load(this.runtime.storage.AssetType.Sound, fileName, 'mp3') + .then(soundAsset => { + const context = this.runtime.audioEngine.audioContext; + // Check for newer promise-based API + if (context.decodeAudioData.length === 1) { + return context.decodeAudioData(soundAsset.data.buffer); + } else { // eslint-disable-line no-else-return + // Fall back to callback API + return new Promise((resolve, reject) => + context.decodeAudioData(soundAsset.data.buffer, + buffer => resolve(buffer), + error => reject(error) + ) + ); + } + }) + .then(buffer => { + bufferArray[index] = buffer; + }); + } + + /** + * Create data for a menu in scratch-blocks format, consisting of an array of objects with text and + * value properties. The text is a translated string, and the value is one-indexed. + * @param {object[]} info - An array of info objects each having a name property. + * @return {array} - An array of objects with text and value properties. + * @private + */ + _buildMenu (info) { + return info.map((entry, index) => { + const obj = {}; + obj.text = entry.name; + obj.value = String(index + 1); + return obj; + }); + } + + /** + * An array of info about each drum. + * @type {object[]} an array of objects. + * @param {string} name - the translatable name to display in the drums menu. + * @param {string} fileName - the name of the audio file containing the drum sound. + */ + get DRUM_INFO () { + return [ + { + name: '(1) Snare Drum', + fileName: '1-snare' + }, + { + name: '(2) Bass Drum', + fileName: '2-bass-drum' + }, + { + name: '(3) Side Stick', + fileName: '3-side-stick' + }, + { + name: '(4) Crash Cymbal', + fileName: '4-crash-cymbal' + }, + { + name: '(5) Open Hi-Hat', + fileName: '5-open-hi-hat' + }, + { + name: '(6) Closed Hi-Hat', + fileName: '6-closed-hi-hat' + }, + { + name: '(7) Tambourine', + fileName: '7-tambourine' + }, + { + name: '(8) Hand Clap', + fileName: '8-hand-clap' + }, + { + name: '(9) Claves', + fileName: '9-claves' + }, + { + name: '(10) Wood Block', + fileName: '10-wood-block' + }, + { + name: '(11) Cowbell', + fileName: '11-cowbell' + }, + { + name: '(12) Triangle', + fileName: '12-triangle' + }, + { + name: '(13) Bongo', + fileName: '13-bongo' + }, + { + name: '(14) Conga', + fileName: '14-conga' + }, + { + name: '(15) Cabasa', + fileName: '15-cabasa' + }, + { + name: '(16) Guiro', + fileName: '16-guiro' + }, + { + name: '(17) Vibraslap', + fileName: '17-vibraslap' + }, + { + name: '(18) Cuica', + fileName: '18-cuica' + } + ]; + } + + /** + * An array of info about each instrument. + * @type {object[]} an array of objects. + * @param {string} name - the translatable name to display in the instruments menu. + * @param {string} dirName - the name of the directory containing audio samples for this instrument. + * @param {number} [releaseTime] - an optional duration for the release portion of each note. + * @param {number[]} samples - an array of numbers representing the MIDI note number for each + * sampled sound used to play this instrument. + */ + get INSTRUMENT_INFO () { + return [ + { + name: '(1) Piano', + dirName: '1-piano', + releaseTime: 0.5, + samples: [24, 36, 48, 60, 72, 84, 96, 108] + }, + { + name: '(2) Electric Piano', + dirName: '2-electric-piano', + releaseTime: 0.5, + samples: [60] + }, + { + name: '(3) Organ', + dirName: '3-organ', + releaseTime: 0.5, + samples: [60] + }, + { + name: '(4) Guitar', + dirName: '4-guitar', + releaseTime: 0.5, + samples: [60] + }, + { + name: '(5) Electric Guitar', + dirName: '5-electric-guitar', + releaseTime: 0.5, + samples: [60] + }, + { + name: '(6) Bass', + dirName: '6-bass', + releaseTime: 0.25, + samples: [36, 48] + }, + { + name: '(7) Pizzicato', + dirName: '7-pizzicato', + releaseTime: 0.25, + samples: [60] + }, + { + name: '(8) Cello', + dirName: '8-cello', + releaseTime: 0.1, + samples: [36, 48, 60] + }, + { + name: '(9) Trombone', + dirName: '9-trombone', + samples: [36, 48, 60] + }, + { + name: '(10) Clarinet', + dirName: '10-clarinet', + samples: [48, 60] + }, + { + name: '(11) Saxophone', + dirName: '11-saxophone', + samples: [36, 60, 84] + }, + { + name: '(12) Flute', + dirName: '12-flute', + samples: [60, 72] + }, + { + name: '(13) Wooden Flute', + dirName: '13-wooden-flute', + samples: [60, 72] + }, + { + name: '(14) Bassoon', + dirName: '14-bassoon', + samples: [36, 48, 60] + }, + { + name: '(15) Choir', + dirName: '15-choir', + releaseTime: 0.25, + samples: [48, 60, 72] + }, + { + name: '(16) Vibraphone', + dirName: '16-vibraphone', + releaseTime: 0.5, + samples: [60, 72] + }, + { + name: '(17) Music Box', + dirName: '17-music-box', + releaseTime: 0.25, + samples: [60] + }, + { + name: '(18) Steel Drum', + dirName: '18-steel-drum', + releaseTime: 0.5, + samples: [60] + }, + { + name: '(19) Marimba', + dirName: '19-marimba', + samples: [60] + }, + { + name: '(20) Synth Lead', + dirName: '20-synth-lead', + releaseTime: 0.1, + samples: [60] + }, + { + name: '(21) Synth Pad', + dirName: '21-synth-pad', + releaseTime: 0.25, + samples: [60] + } + ]; + } + + /** + * The key to load & store a target's music-related state. + * @type {string} + */ + static get STATE_KEY () { + return 'Scratch.music'; + } + + /** + * The default music-related state, to be used when a target has no existing music state. + * @type {MusicState} + */ + static get DEFAULT_MUSIC_STATE () { + return { + currentInstrument: 0 + }; + } + + /** + * The minimum and maximum MIDI note numbers, for clamping the input to play note. + * @type {{min: number, max: number}} + */ + static get MIDI_NOTE_RANGE () { + return {min: 0, max: 130}; + } + + /** + * The minimum and maximum beat values, for clamping the duration of play note, play drum and rest. + * 100 beats at the default tempo of 60bpm is 100 seconds. + * @type {{min: number, max: number}} + */ + static get BEAT_RANGE () { + return {min: 0, max: 100}; + } + + /** The minimum and maximum tempo values, in bpm. + * @type {{min: number, max: number}} + */ + static get TEMPO_RANGE () { + return {min: 20, max: 500}; + } + + /** + * The maximum number of sounds to allow to play simultaneously. + * @type {number} + */ + static get CONCURRENCY_LIMIT () { + return 30; + } + + /** + * @param {Target} target - collect music state for this target. + * @returns {MusicState} the mutable music state associated with that target. This will be created if necessary. + * @private + */ + _getMusicState (target) { + let musicState = target.getCustomState(Scratch3MusicBlocks.STATE_KEY); + if (!musicState) { + musicState = Clone.simple(Scratch3MusicBlocks.DEFAULT_MUSIC_STATE); + target.setCustomState(Scratch3MusicBlocks.STATE_KEY, musicState); + } + return musicState; + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: 'music', + name: 'Music', + menuIconURI: menuIconURI, + blockIconURI: blockIconURI, + blocks: [ + { + opcode: 'playDrumForBeats', + blockType: BlockType.COMMAND, + text: 'play drum [DRUM] for [BEATS] beats', + arguments: { + DRUM: { + type: ArgumentType.NUMBER, + menu: 'drums', + defaultValue: 1 + }, + BEATS: { + type: ArgumentType.NUMBER, + defaultValue: 0.25 + } + } + }, + { + opcode: 'restForBeats', + blockType: BlockType.COMMAND, + text: 'rest for [BEATS] beats', + arguments: { + BEATS: { + type: ArgumentType.NUMBER, + defaultValue: 0.25 + } + } + }, + { + opcode: 'playNoteForBeats', + blockType: BlockType.COMMAND, + text: 'play note [NOTE] for [BEATS] beats', + arguments: { + NOTE: { + type: ArgumentType.NUMBER, + defaultValue: 60 + }, + BEATS: { + type: ArgumentType.NUMBER, + defaultValue: 0.25 + } + } + }, + { + opcode: 'setInstrument', + blockType: BlockType.COMMAND, + text: 'set instrument to [INSTRUMENT]', + arguments: { + INSTRUMENT: { + type: ArgumentType.NUMBER, + menu: 'instruments', + defaultValue: 1 + } + } + }, + { + opcode: 'setTempo', + blockType: BlockType.COMMAND, + text: 'set tempo to [TEMPO]', + arguments: { + TEMPO: { + type: ArgumentType.NUMBER, + defaultValue: 60 + } + } + }, + { + opcode: 'changeTempo', + blockType: BlockType.COMMAND, + text: 'change tempo by [TEMPO]', + arguments: { + TEMPO: { + type: ArgumentType.NUMBER, + defaultValue: 20 + } + } + }, + { + opcode: 'getTempo', + text: 'tempo', + blockType: BlockType.REPORTER + } + ], + menus: { + drums: this._buildMenu(this.DRUM_INFO), + instruments: this._buildMenu(this.INSTRUMENT_INFO) + } + }; + } + + /** + * Play a drum sound for some number of beats. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + * @property {int} DRUM - the number of the drum to play. + * @property {number} BEATS - the duration in beats of the drum sound. + */ + playDrumForBeats (args, util) { + if (this._stackTimerNeedsInit(util)) { + let drum = Cast.toNumber(args.DRUM); + drum = Math.round(drum); + drum -= 1; // drums are one-indexed + drum = MathUtil.wrapClamp(drum, 0, this.DRUM_INFO.length - 1); + let beats = Cast.toNumber(args.BEATS); + beats = this._clampBeats(beats); + this._playDrumNum(util, drum); + this._startStackTimer(util, this._beatsToSec(beats)); + } else { + this._checkStackTimer(util); + } + } + + /** + * Play a drum sound using its 0-indexed number. + * @param {object} util - utility object provided by the runtime. + * @param {number} drumNum - the number of the drum to play. + * @private + */ + _playDrumNum (util, drumNum) { + if (util.runtime.audioEngine === null) return; + if (util.target.audioPlayer === null) return; + // If we're playing too many sounds, do not play the drum sound. + if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { + return; + } + const outputNode = util.target.audioPlayer.getInputNode(); + const context = util.runtime.audioEngine.audioContext; + const bufferSource = context.createBufferSource(); + bufferSource.buffer = this._drumBuffers[drumNum]; + bufferSource.connect(outputNode); + bufferSource.start(); + + const bufferSourceIndex = this._bufferSources.length; + this._bufferSources.push(bufferSource); + + this._concurrencyCounter++; + bufferSource.onended = () => { + this._concurrencyCounter--; + delete this._bufferSources[bufferSourceIndex]; + }; + } + + /** + * Rest for some number of beats. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + * @property {number} BEATS - the duration in beats of the rest. + */ + restForBeats (args, util) { + if (this._stackTimerNeedsInit(util)) { + let beats = Cast.toNumber(args.BEATS); + beats = this._clampBeats(beats); + this._startStackTimer(util, this._beatsToSec(beats)); + } else { + this._checkStackTimer(util); + } + } + + /** + * Play a note using the current musical instrument for some number of beats. + * This function processes the arguments, and handles the timing of the block's execution. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + * @property {number} NOTE - the pitch of the note to play, interpreted as a MIDI note number. + * @property {number} BEATS - the duration in beats of the note. + */ + playNoteForBeats (args, util) { + if (this._stackTimerNeedsInit(util)) { + let note = Cast.toNumber(args.NOTE); + note = MathUtil.clamp(note, + Scratch3MusicBlocks.MIDI_NOTE_RANGE.min, Scratch3MusicBlocks.MIDI_NOTE_RANGE.max); + let beats = Cast.toNumber(args.BEATS); + beats = this._clampBeats(beats); + // If the duration is 0, do not play the note. In Scratch 2.0, "play drum for 0 beats" plays the drum, + // but "play note for 0 beats" is silent. + if (beats === 0) return; + + const durationSec = this._beatsToSec(beats); + + this._playNote(util, note, durationSec); + + this._startStackTimer(util, durationSec); + } else { + this._checkStackTimer(util); + } + } + + /** + * Play a note using the current instrument for a duration in seconds. + * This function actually plays the sound, and handles the timing of the sound, including the + * "release" portion of the sound, which continues briefly after the block execution has finished. + * @param {object} util - utility object provided by the runtime. + * @param {number} note - the pitch of the note to play, interpreted as a MIDI note number. + * @param {number} durationSec - the duration in seconds to play the note. + * @private + */ + _playNote (util, note, durationSec) { + if (util.runtime.audioEngine === null) return; + if (util.target.audioPlayer === null) return; + + // If we're playing too many sounds, do not play the note. + if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { + return; + } + + // Determine which of the audio samples for this instrument to play + const musicState = this._getMusicState(util.target); + const inst = musicState.currentInstrument; + const instrumentInfo = this.INSTRUMENT_INFO[inst]; + const sampleArray = instrumentInfo.samples; + const sampleIndex = this._selectSampleIndexForNote(note, sampleArray); + + // If the audio sample has not loaded yet, bail out + if (typeof this._instrumentBufferArrays[inst] === 'undefined') return; + if (typeof this._instrumentBufferArrays[inst][sampleIndex] === 'undefined') return; + + // Create the audio buffer to play the note, and set its pitch + const context = util.runtime.audioEngine.audioContext; + const bufferSource = context.createBufferSource(); + + const bufferSourceIndex = this._bufferSources.length; + this._bufferSources.push(bufferSource); + + bufferSource.buffer = this._instrumentBufferArrays[inst][sampleIndex]; + const sampleNote = sampleArray[sampleIndex]; + bufferSource.playbackRate.value = this._ratioForPitchInterval(note - sampleNote); + + // Create a gain node for this note, and connect it to the sprite's audioPlayer. + const gainNode = context.createGain(); + bufferSource.connect(gainNode); + const outputNode = util.target.audioPlayer.getInputNode(); + gainNode.connect(outputNode); + + // Start playing the note + bufferSource.start(); + + // Schedule the release of the note, ramping its gain down to zero, + // and then stopping the sound. + let releaseDuration = this.INSTRUMENT_INFO[inst].releaseTime; + if (typeof releaseDuration === 'undefined') { + releaseDuration = 0.01; + } + const releaseStart = context.currentTime + durationSec; + const releaseEnd = releaseStart + releaseDuration; + gainNode.gain.setValueAtTime(1, releaseStart); + gainNode.gain.linearRampToValueAtTime(0.0001, releaseEnd); + bufferSource.stop(releaseEnd); + + // Update the concurrency counter + this._concurrencyCounter++; + bufferSource.onended = () => { + this._concurrencyCounter--; + delete this._bufferSources[bufferSourceIndex]; + }; + } + + /** + * The samples array for each instrument is the set of pitches of the available audio samples. + * This function selects the best one to use to play a given input note, and returns its index + * in the samples array. + * @param {number} note - the input note to select a sample for. + * @param {number[]} samples - an array of the pitches of the available samples. + * @return {index} the index of the selected sample in the samples array. + * @private + */ + _selectSampleIndexForNote (note, samples) { + // Step backwards through the array of samples, i.e. in descending pitch, in order to find + // the sample that is the closest one below (or matching) the pitch of the input note. + for (let i = samples.length - 1; i >= 0; i--) { + if (note >= samples[i]) { + return i; + } + } + return 0; + } + + /** + * Calcuate the frequency ratio for a given musical interval. + * @param {number} interval - the pitch interval to convert. + * @return {number} a ratio corresponding to the input interval. + * @private + */ + _ratioForPitchInterval (interval) { + return Math.pow(2, (interval / 12)); + } + + /** + * Clamp a duration in beats to the allowed min and max duration. + * @param {number} beats - a duration in beats. + * @return {number} - the clamped duration. + * @private + */ + _clampBeats (beats) { + return MathUtil.clamp(beats, Scratch3MusicBlocks.BEAT_RANGE.min, Scratch3MusicBlocks.BEAT_RANGE.max); + } + + /** + * Convert a number of beats to a number of seconds, using the current tempo. + * @param {number} beats - number of beats to convert to secs. + * @return {number} seconds - number of seconds `beats` will last. + * @private + */ + _beatsToSec (beats) { + return (60 / this.tempo) * beats; + } + + /** + * Check if the stack timer needs initialization. + * @param {object} util - utility object provided by the runtime. + * @return {boolean} - true if the stack timer needs to be initialized. + * @private + */ + _stackTimerNeedsInit (util) { + return !util.stackFrame.timer; + } + + /** + * Start the stack timer and the yield the thread if necessary. + * @param {object} util - utility object provided by the runtime. + * @param {number} duration - a duration in seconds to set the timer for. + * @private + */ + _startStackTimer (util, duration) { + util.stackFrame.timer = new Timer(); + util.stackFrame.timer.start(); + util.stackFrame.duration = duration; + util.yield(); + } + + /** + * Check the stack timer, and if its time is not up yet, yield the thread. + * @param {object} util - utility object provided by the runtime. + * @private + */ + _checkStackTimer (util) { + const timeElapsed = util.stackFrame.timer.timeElapsed(); + if (timeElapsed < util.stackFrame.duration * 1000) { + util.yield(); + } + } + + /** + * Select an instrument for playing notes. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + * @property {int} INSTRUMENT - the number of the instrument to select. + */ + setInstrument (args, util) { + const musicState = this._getMusicState(util.target); + let instNum = Cast.toNumber(args.INSTRUMENT); + instNum = Math.round(instNum); + instNum -= 1; // instruments are one-indexed + instNum = MathUtil.wrapClamp(instNum, 0, this.INSTRUMENT_INFO.length - 1); + musicState.currentInstrument = instNum; + } + + /** + * Set the current tempo to a new value. + * @param {object} args - the block arguments. + * @property {number} TEMPO - the tempo, in beats per minute. + */ + setTempo (args) { + const tempo = Cast.toNumber(args.TEMPO); + this._updateTempo(tempo); + } + + /** + * Change the current tempo by some amount. + * @param {object} args - the block arguments. + * @property {number} TEMPO - the amount to change the tempo, in beats per minute. + */ + changeTempo (args) { + const change = Cast.toNumber(args.TEMPO); + const tempo = change + this.tempo; + this._updateTempo(tempo); + } + + /** + * Update the current tempo, clamping it to the min and max allowable range. + * @param {number} tempo - the tempo to set, in beats per minute. + * @private + */ + _updateTempo (tempo) { + tempo = MathUtil.clamp(tempo, Scratch3MusicBlocks.TEMPO_RANGE.min, Scratch3MusicBlocks.TEMPO_RANGE.max); + this.tempo = tempo; + } + + /** + * Get the current tempo. + * @return {number} - the current tempo, in beats per minute. + */ + getTempo () { + return this.tempo; + } +} + +module.exports = Scratch3MusicBlocks; diff --git a/src/extensions/scratch3_pen/index.js b/src/extensions/scratch3_pen/index.js new file mode 100644 index 00000000..c6540016 --- /dev/null +++ b/src/extensions/scratch3_pen/index.js @@ -0,0 +1,760 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const Cast = require('../../util/cast'); +const Clone = require('../../util/clone'); +const Color = require('../../util/color'); +const formatMessage = require('format-message'); +const MathUtil = require('../../util/math-util'); +const RenderedTarget = require('../../sprites/rendered-target'); +const log = require('../../util/log'); + +/** + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+cGVuLWljb248L3RpdGxlPjxnIHN0cm9rZT0iIzU3NUU3NSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik04Ljc1MyAzNC42MDJsLTQuMjUgMS43OCAxLjc4My00LjIzN2MxLjIxOC0yLjg5MiAyLjkwNy01LjQyMyA1LjAzLTcuNTM4TDMxLjA2NiA0LjkzYy44NDYtLjg0MiAyLjY1LS40MSA0LjAzMi45NjcgMS4zOCAxLjM3NSAxLjgxNiAzLjE3My45NyA0LjAxNUwxNi4zMTggMjkuNTljLTIuMTIzIDIuMTE2LTQuNjY0IDMuOC03LjU2NSA1LjAxMiIgZmlsbD0iI0ZGRiIvPjxwYXRoIGQ9Ik0yOS40MSA2LjExcy00LjQ1LTIuMzc4LTguMjAyIDUuNzcyYy0xLjczNCAzLjc2Ni00LjM1IDEuNTQ2LTQuMzUgMS41NDYiLz48cGF0aCBkPSJNMzYuNDIgOC44MjVjMCAuNDYzLS4xNC44NzMtLjQzMiAxLjE2NGwtOS4zMzUgOS4zYy4yODItLjI5LjQxLS42NjguNDEtMS4xMiAwLS44NzQtLjUwNy0xLjk2My0xLjQwNi0yLjg2OC0xLjM2Mi0xLjM1OC0zLjE0Ny0xLjgtNC4wMDItLjk5TDMwLjk5IDUuMDFjLjg0NC0uODQgMi42NS0uNDEgNC4wMzUuOTYuODk4LjkwNCAxLjM5NiAxLjk4MiAxLjM5NiAyLjg1NU0xMC41MTUgMzMuNzc0Yy0uNTczLjMwMi0xLjE1Ny41Ny0xLjc2NC44M0w0LjUgMzYuMzgybDEuNzg2LTQuMjM1Yy4yNTgtLjYwNC41My0xLjE4Ni44MzMtMS43NTcuNjkuMTgzIDEuNDQ4LjYyNSAyLjEwOCAxLjI4Mi42Ni42NTggMS4xMDIgMS40MTIgMS4yODcgMi4xMDIiIGZpbGw9IiM0Qzk3RkYiLz48cGF0aCBkPSJNMzYuNDk4IDguNzQ4YzAgLjQ2NC0uMTQuODc0LS40MzMgMS4xNjVsLTE5Ljc0MiAxOS42OGMtMi4xMyAyLjExLTQuNjczIDMuNzkzLTcuNTcyIDUuMDFMNC41IDM2LjM4bC45NzQtMi4zMTYgMS45MjUtLjgwOGMyLjg5OC0xLjIxOCA1LjQ0LTIuOSA3LjU3LTUuMDFsMTkuNzQzLTE5LjY4Yy4yOTItLjI5Mi40MzItLjcwMi40MzItMS4xNjUgMC0uNjQ2LS4yNy0xLjQtLjc4LTIuMTIyLjI1LjE3Mi41LjM3Ny43MzcuNjE0Ljg5OC45MDUgMS4zOTYgMS45ODMgMS4zOTYgMi44NTYiIGZpbGw9IiM1NzVFNzUiIG9wYWNpdHk9Ii4xNSIvPjxwYXRoIGQ9Ik0xOC40NSAxMi44M2MwIC41LS40MDQuOTA1LS45MDQuOTA1cy0uOTA1LS40MDUtLjkwNS0uOTA0YzAtLjUuNDA3LS45MDMuOTA2LS45MDMuNSAwIC45MDQuNDA0LjkwNC45MDR6IiBmaWxsPSIjNTc1RTc1Ii8+PC9nPjwvc3ZnPg=='; + +/** + * Enum for pen color parameter values. + * @readonly + * @enum {string} + */ +const ColorParam = { + COLOR: 'color', + SATURATION: 'saturation', + BRIGHTNESS: 'brightness', + TRANSPARENCY: 'transparency' +}; + +/** + * @typedef {object} PenState - the pen state associated with a particular target. + * @property {Boolean} penDown - tracks whether the pen should draw for this target. + * @property {number} color - the current color (hue) of the pen. + * @property {PenAttributes} penAttributes - cached pen attributes for the renderer. This is the authoritative value for + * diameter but not for pen color. + */ + +/** + * Host for the Pen-related blocks in Scratch 3.0 + * @param {Runtime} runtime - the runtime instantiating this block package. + * @constructor + */ +class Scratch3PenBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + /** + * The ID of the renderer Drawable corresponding to the pen layer. + * @type {int} + * @private + */ + this._penDrawableId = -1; + + /** + * The ID of the renderer Skin corresponding to the pen layer. + * @type {int} + * @private + */ + this._penSkinId = -1; + + this._onTargetCreated = this._onTargetCreated.bind(this); + this._onTargetMoved = this._onTargetMoved.bind(this); + + runtime.on('targetWasCreated', this._onTargetCreated); + } + + /** + * The default pen state, to be used when a target has no existing pen state. + * @type {PenState} + */ + static get DEFAULT_PEN_STATE () { + return { + penDown: false, + color: 66.66, + saturation: 100, + brightness: 100, + transparency: 0, + _shade: 50, // Used only for legacy `change shade by` blocks + penAttributes: { + color4f: [0, 0, 1, 1], + diameter: 1 + } + }; + } + + /** + * Place the pen layer in front of the backdrop but behind everything else. + * We should probably handle this somewhere else... somewhere central that knows about pen, backdrop, video, etc. + * Maybe it should be in the GUI? + * @type {int} + */ + static get PEN_ORDER () { + return 1; + } + + /** + * The minimum and maximum allowed pen size. + * @type {{min: number, max: number}} + */ + static get PEN_SIZE_RANGE () { + return {min: 1, max: 255}; + } + + /** + * The key to load & store a target's pen-related state. + * @type {string} + */ + static get STATE_KEY () { + return 'Scratch.pen'; + } + + /** + * Clamp a pen size value to the range allowed by the pen. + * @param {number} requestedSize - the requested pen size. + * @returns {number} the clamped size. + * @private + */ + _clampPenSize (requestedSize) { + return MathUtil.clamp( + requestedSize, + Scratch3PenBlocks.PEN_SIZE_RANGE.min, + Scratch3PenBlocks.PEN_SIZE_RANGE.max + ); + } + + /** + * Retrieve the ID of the renderer "Skin" corresponding to the pen layer. If + * the pen Skin doesn't yet exist, create it. + * @returns {int} the Skin ID of the pen layer, or -1 on failure. + * @private + */ + _getPenLayerID () { + if (this._penSkinId < 0 && this.runtime.renderer) { + this._penSkinId = this.runtime.renderer.createPenSkin(); + this._penDrawableId = this.runtime.renderer.createDrawable(); + this.runtime.renderer.setDrawableOrder(this._penDrawableId, Scratch3PenBlocks.PEN_ORDER); + this.runtime.renderer.updateDrawableProperties(this._penDrawableId, {skinId: this._penSkinId}); + } + return this._penSkinId; + } + + /** + * @param {Target} target - collect pen state for this target. Probably, but not necessarily, a RenderedTarget. + * @returns {PenState} the mutable pen state associated with that target. This will be created if necessary. + * @private + */ + _getPenState (target) { + let penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY); + if (!penState) { + penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE); + target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState); + } + return penState; + } + + /** + * When a pen-using Target is cloned, clone the pen state. + * @param {Target} newTarget - the newly created target. + * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. + * @listens Runtime#event:targetWasCreated + * @private + */ + _onTargetCreated (newTarget, sourceTarget) { + if (sourceTarget) { + const penState = sourceTarget.getCustomState(Scratch3PenBlocks.STATE_KEY); + if (penState) { + newTarget.setCustomState(Scratch3PenBlocks.STATE_KEY, Clone.simple(penState)); + if (penState.penDown) { + newTarget.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } + } + } + } + + /** + * Handle a target which has moved. This only fires when the pen is down. + * @param {RenderedTarget} target - the target which has moved. + * @param {number} oldX - the previous X position. + * @param {number} oldY - the previous Y position. + * @param {boolean} isForce - whether the movement was forced. + * @private + */ + _onTargetMoved (target, oldX, oldY, isForce) { + // Only move the pen if the movement isn't forced (ie. dragged). + if (!isForce) { + const penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + const penState = this._getPenState(target); + this.runtime.renderer.penLine(penSkinId, penState.penAttributes, oldX, oldY, target.x, target.y); + this.runtime.requestRedraw(); + } + } + } + + /** + * Wrap a color input into the range (0,100). + * @param {number} value - the value to be wrapped. + * @returns {number} the wrapped value. + * @private + */ + _wrapColor (value) { + return MathUtil.wrapClamp(value, 0, 100); + } + + /** + * Initialize color parameters menu with localized strings + * @returns {array} of the localized text and values for each menu element + * @private + */ + _initColorParam () { + return [ + { + text: formatMessage({ + id: 'pen.colorMenu.color', + default: 'color', + description: 'label for color element in color picker for pen extension' + }), + value: ColorParam.COLOR + }, + { + text: formatMessage({ + id: 'pen.colorMenu.saturation', + default: 'saturation', + description: 'label for saturation element in color picker for pen extension' + }), + value: ColorParam.SATURATION + }, + { + text: formatMessage({ + id: 'pen.colorMenu.brightness', + default: 'brightness', + description: 'label for brightness element in color picker for pen extension' + }), + value: ColorParam.BRIGHTNESS + }, + { + text: formatMessage({ + id: 'pen.colorMenu.transparency', + default: 'transparency', + description: 'label for transparency element in color picker for pen extension' + }), + value: ColorParam.TRANSPARENCY + + } + ]; + } + + /** + * Clamp a pen color parameter to the range (0,100). + * @param {number} value - the value to be clamped. + * @returns {number} the clamped value. + * @private + */ + _clampColorParam (value) { + return MathUtil.clamp(value, 0, 100); + } + + /** + * Convert an alpha value to a pen transparency value. + * Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque. + * Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent. + * @param {number} alpha - the input alpha value. + * @returns {number} the transparency value. + * @private + */ + _alphaToTransparency (alpha) { + return (1.0 - alpha) * 100.0; + } + + /** + * Convert a pen transparency value to an alpha value. + * Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque. + * Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent. + * @param {number} transparency - the input transparency value. + * @returns {number} the alpha value. + * @private + */ + _transparencyToAlpha (transparency) { + return 1.0 - (transparency / 100.0); + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: 'pen', + name: 'Pen', + blockIconURI: blockIconURI, + blocks: [ + { + opcode: 'clear', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.clear', + default: 'clear', + description: 'erase all pen trails and stamps' + }) + }, + { + opcode: 'stamp', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.stamp', + default: 'stamp', + description: 'render current costume on the background' + }) + }, + { + opcode: 'penDown', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.penDown', + default: 'pen down', + description: 'start leaving a trail when the sprite moves' + }) + }, + { + opcode: 'penUp', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.penUp', + default: 'pen up', + description: 'stop leaving a trail behind the sprite' + }) + }, + { + opcode: 'setPenColorToColor', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.setColor', + default: 'set pen color to [COLOR]', + description: 'set the pen color to a particular (RGB) value' + }), + arguments: { + COLOR: { + type: ArgumentType.COLOR + } + } + }, + { + opcode: 'changePenColorParamBy', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.changeColorParam', + default: 'change pen [COLOR_PARAM] by [VALUE]', + description: 'change the state of a pen color parameter' + }), + arguments: { + COLOR_PARAM: { + type: ArgumentType.STRING, + menu: 'colorParam', + defaultValue: ColorParam.COLOR + }, + VALUE: { + type: ArgumentType.NUMBER, + defaultValue: 10 + } + } + }, + { + opcode: 'setPenColorParamTo', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.setColorParam', + default: 'set pen [COLOR_PARAM] to [VALUE]', + description: 'set the state for a pen color parameter e.g. saturation' + }), + arguments: { + COLOR_PARAM: { + type: ArgumentType.STRING, + menu: 'colorParam', + defaultValue: ColorParam.COLOR + }, + VALUE: { + type: ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'changePenSizeBy', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.changeSize', + default: 'change pen size by [SIZE]', + description: 'change the diameter of the trail left by a sprite' + }), + arguments: { + SIZE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'setPenSizeTo', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.setSize', + default: 'set pen size to [SIZE]', + description: 'set the diameter of a trail left by a sprite' + }), + arguments: { + SIZE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + /* Legacy blocks, should not be shown in flyout */ + { + opcode: 'setPenShadeToNumber', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.setShade', + default: 'set pen shade to [SHADE]', + description: 'legacy pen blocks - set pen shade' + }), + arguments: { + SHADE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + }, + hideFromPalette: true + }, + { + opcode: 'changePenShadeBy', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.changeShade', + default: 'change pen shade by [SHADE]', + description: 'legacy pen blocks - change pen shade' + }), + arguments: { + SHADE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + }, + hideFromPalette: true + }, + { + opcode: 'setPenHueToNumber', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.setHue', + default: 'set pen color to [HUE]', + description: 'legacy pen blocks - set pen color to number' + }), + arguments: { + HUE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + }, + hideFromPalette: true + }, + { + opcode: 'changePenHueBy', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'pen.changeHue', + default: 'change pen color by [HUE]', + description: 'legacy pen blocks - change pen color' + }), + arguments: { + HUE: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + }, + hideFromPalette: true + } + ], + menus: { + colorParam: this._initColorParam() + } + }; + } + + /** + * The pen "clear" block clears the pen layer's contents. + */ + clear () { + const penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + this.runtime.renderer.penClear(penSkinId); + this.runtime.requestRedraw(); + } + } + + /** + * The pen "stamp" block stamps the current drawable's image onto the pen layer. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ + stamp (args, util) { + const penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + const target = util.target; + this.runtime.renderer.penStamp(penSkinId, target.drawableID); + this.runtime.requestRedraw(); + } + } + + /** + * The pen "pen down" block causes the target to leave pen trails on future motion. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ + penDown (args, util) { + const target = util.target; + const penState = this._getPenState(target); + + if (!penState.penDown) { + penState.penDown = true; + target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } + + const penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + this.runtime.renderer.penPoint(penSkinId, penState.penAttributes, target.x, target.y); + this.runtime.requestRedraw(); + } + } + + /** + * The pen "pen up" block stops the target from leaving pen trails. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ + penUp (args, util) { + const target = util.target; + const penState = this._getPenState(target); + + if (penState.penDown) { + penState.penDown = false; + target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } + } + + /** + * The pen "set pen color to {color}" block sets the pen to a particular RGB color. + * The transparency is reset to 0. + * @param {object} args - the block arguments. + * @property {int} COLOR - the color to set, expressed as a 24-bit RGB value (0xRRGGBB). + * @param {object} util - utility object provided by the runtime. + */ + setPenColorToColor (args, util) { + const penState = this._getPenState(util.target); + const rgb = Cast.toRgbColorObject(args.COLOR); + const hsv = Color.rgbToHsv(rgb); + penState.color = (hsv.h / 360) * 100; + penState.saturation = hsv.s * 100; + penState.brightness = hsv.v * 100; + if (rgb.hasOwnProperty('a')) { + penState.transparency = 100 * (1 - (rgb.a / 255.0)); + } else { + penState.transparency = 0; + } + + // Set the legacy "shade" value the same way scratch 2 did. + penState._shade = penState.brightness / 2; + + this._updatePenColor(penState); + } + + /** + * Update the cached color from the color, saturation, brightness and transparency values + * in the provided PenState object. + * @param {PenState} penState - the pen state to update. + * @private + */ + _updatePenColor (penState) { + const rgb = Color.hsvToRgb({ + h: penState.color * 360 / 100, + s: penState.saturation / 100, + v: penState.brightness / 100 + }); + penState.penAttributes.color4f[0] = rgb.r / 255.0; + penState.penAttributes.color4f[1] = rgb.g / 255.0; + penState.penAttributes.color4f[2] = rgb.b / 255.0; + penState.penAttributes.color4f[3] = this._transparencyToAlpha(penState.transparency); + } + + /** + * Set or change a single color parameter on the pen state, and update the pen color. + * @param {ColorParam} param - the name of the color parameter to set or change. + * @param {number} value - the value to set or change the param by. + * @param {PenState} penState - the pen state to update. + * @param {boolean} change - if true change param by value, if false set param to value. + * @private + */ + _setOrChangeColorParam (param, value, penState, change) { + switch (param) { + case ColorParam.COLOR: + penState.color = this._wrapColor(value + (change ? penState.color : 0)); + break; + case ColorParam.SATURATION: + penState.saturation = this._clampColorParam(value + (change ? penState.saturation : 0)); + break; + case ColorParam.BRIGHTNESS: + penState.brightness = this._clampColorParam(value + (change ? penState.brightness : 0)); + break; + case ColorParam.TRANSPARENCY: + penState.transparency = this._clampColorParam(value + (change ? penState.transparency : 0)); + break; + default: + log.warn(`Tried to set or change unknown color parameter: ${param}`); + } + this._updatePenColor(penState); + } + + /** + * The "change pen {ColorParam} by {number}" block changes one of the pen's color parameters + * by a given amound. + * @param {object} args - the block arguments. + * @property {ColorParam} COLOR_PARAM - the name of the selected color parameter. + * @property {number} VALUE - the amount to change the selected parameter by. + * @param {object} util - utility object provided by the runtime. + */ + changePenColorParamBy (args, util) { + const penState = this._getPenState(util.target); + this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, true); + } + + /** + * The "set pen {ColorParam} to {number}" block sets one of the pen's color parameters + * to a given amound. + * @param {object} args - the block arguments. + * @property {ColorParam} COLOR_PARAM - the name of the selected color parameter. + * @property {number} VALUE - the amount to set the selected parameter to. + * @param {object} util - utility object provided by the runtime. + */ + setPenColorParamTo (args, util) { + const penState = this._getPenState(util.target); + this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, false); + } + + /** + * The pen "change pen size by {number}" block changes the pen size by the given amount. + * @param {object} args - the block arguments. + * @property {number} SIZE - the amount of desired size change. + * @param {object} util - utility object provided by the runtime. + */ + changePenSizeBy (args, util) { + const penAttributes = this._getPenState(util.target).penAttributes; + penAttributes.diameter = this._clampPenSize(penAttributes.diameter + Cast.toNumber(args.SIZE)); + } + + /** + * The pen "set pen size to {number}" block sets the pen size to the given amount. + * @param {object} args - the block arguments. + * @property {number} SIZE - the amount of desired size change. + * @param {object} util - utility object provided by the runtime. + */ + setPenSizeTo (args, util) { + const penAttributes = this._getPenState(util.target).penAttributes; + penAttributes.diameter = this._clampPenSize(Cast.toNumber(args.SIZE)); + } + + /* LEGACY OPCODES */ + /** + * Scratch 2 "hue" param is equivelant to twice the new "color" param. + * @param {object} args - the block arguments. + * @property {number} HUE - the amount to set the hue to. + * @param {object} util - utility object provided by the runtime. + */ + setPenHueToNumber (args, util) { + const penState = this._getPenState(util.target); + const hueValue = Cast.toNumber(args.HUE); + const colorValue = hueValue / 2; + this._setOrChangeColorParam(ColorParam.COLOR, colorValue, penState, false); + + this._legacyUpdatePenColor(penState); + } + + /** + * Scratch 2 "hue" param is equivelant to twice the new "color" param. + * @param {object} args - the block arguments. + * @property {number} HUE - the amount of desired hue change. + * @param {object} util - utility object provided by the runtime. + */ + changePenHueBy (args, util) { + const penState = this._getPenState(util.target); + const hueChange = Cast.toNumber(args.HUE); + const colorChange = hueChange / 2; + this._setOrChangeColorParam(ColorParam.COLOR, colorChange, penState, true); + + this._legacyUpdatePenColor(penState); + } + + /** + * Use legacy "set shade" code to calculate RGB value for shade, + * then convert back to HSV and store those components. + * It is important to also track the given shade in penState._shade + * because it cannot be accurately backed out of the new HSV later. + * @param {object} args - the block arguments. + * @property {number} SHADE - the amount to set the shade to. + * @param {object} util - utility object provided by the runtime. + */ + setPenShadeToNumber (args, util) { + const penState = this._getPenState(util.target); + let newShade = Cast.toNumber(args.SHADE); + + // Wrap clamp the new shade value the way scratch 2 did. + newShade = newShade % 200; + if (newShade < 0) newShade += 200; + + // And store the shade that was used to compute this new color for later use. + penState._shade = newShade; + + this._legacyUpdatePenColor(penState); + } + + /** + * Because "shade" cannot be backed out of hsv consistently, use the previously + * stored penState._shade to make the shade change. + * @param {object} args - the block arguments. + * @property {number} SHADE - the amount of desired shade change. + * @param {object} util - utility object provided by the runtime. + */ + changePenShadeBy (args, util) { + const penState = this._getPenState(util.target); + const shadeChange = Cast.toNumber(args.SHADE); + this.setPenShadeToNumber({SHADE: penState._shade + shadeChange}, util); + } + + /** + * Update the pen state's color from its hue & shade values, Scratch 2.0 style. + * @param {object} penState - update the HSV & RGB values in this pen state from its hue & shade values. + * @private + */ + _legacyUpdatePenColor (penState) { + // Create the new color in RGB using the scratch 2 "shade" model + let rgb = Color.hsvToRgb({h: penState.color * 360 / 100, s: 1, v: 1}); + const shade = (penState._shade > 100) ? 200 - penState._shade : penState._shade; + if (shade < 50) { + rgb = Color.mixRgb(Color.RGB_BLACK, rgb, (10 + shade) / 60); + } else { + rgb = Color.mixRgb(rgb, Color.RGB_WHITE, (shade - 50) / 60); + } + + // Update the pen state according to new color + const hsv = Color.rgbToHsv(rgb); + penState.color = 100 * hsv.h / 360; + penState.saturation = 100 * hsv.s; + penState.brightness = 100 * hsv.v; + + this._updatePenColor(penState); + } +} + +module.exports = Scratch3PenBlocks; diff --git a/src/extensions/scratch3_wedo2/index.js b/src/extensions/scratch3_wedo2/index.js new file mode 100644 index 00000000..ad1fd14d --- /dev/null +++ b/src/extensions/scratch3_wedo2/index.js @@ -0,0 +1,868 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const color = require('../../util/color'); +const log = require('../../util/log'); + +/** + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const iconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+d2VkbzItYmxvY2staWNvbjwvdGl0bGU+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMzUuMzEzIDEwLjQ2N0gzMi4wOVY4Ljg2NWMwLS4yMjMuMTgtLjQwNC40MDUtLjQwNGgyLjQxMmMuMjI0IDAgLjQwNi4xODIuNDA2LjQwNXYxLjYwMnpNMzAuNDc3IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4My0uNDA0LjQwNy0uNDA0aDIuNDFjLjIyNiAwIC40MDcuMTgyLjQwNy40MDV2MS42MDJ6TTI1LjY0IDEwLjQ2N0gyMi40MlY4Ljg2NWMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xODIuNDA3LjQwNXYxLjYwMnpNMjAuODA2IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4Mi0uNDA0LjQwNi0uNDA0SDIwLjRjLjIyNCAwIC40MDYuMTgyLjQwNi40MDV2MS42MDJ6TTE1Ljk3IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4Mi0uNDA0LjQwNy0uNDA0aDIuNDFjLjIyNiAwIC40MDcuMTgyLjQwNy40MDV2MS42MDJ6TTExLjEzNSAxMC40NjdINy45MVY4Ljg2NWMwLS4yMjMuMTgzLS40MDQuNDA3LS40MDRoMi40MTJjLjIyMyAwIC40MDUuMTgyLjQwNS40MDV2MS42MDJ6IiBzdHJva2U9IiM2Rjc4OTMiIGZpbGw9IiNGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zNy43MyAxMC40NjdINi4zYy0yLjY3IDAtNC44MzYgMi4xNTMtNC44MzYgNC44MDh2My4yMDVoMzcuMDczdi03LjIxYzAtLjQ0NC0uMzYyLS44MDMtLjgwNy0uODAzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjRkZGIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMzguMTM0IDMwLjk4SDEuODY3Yy0uMjI0IDAtLjQwMy0uMTgtLjQwMy0uNFYxNi4yMzZoMzIuNzFjLjczIDAgMS40My4yODcgMS45NDUuOC41MTUuNTE0IDEuMjE1LjgwMiAxLjk0NC44MDJoLjQ3M3YxMi43NGMwIC4yMi0uMTguNC0uNDAzLjR6IiBzdHJva2U9IiM2Rjc4OTMiIGZpbGw9IiNFNkU3RTgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIHN0cm9rZT0iIzZGNzg5MyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJNMzQuODMgMTYuMjM3bC40ODMtMi41NjVoMy4yMjMiLz48cGF0aCBkPSJNMzguNTM2IDExLjI2OFYzMC41OGMwIC4yMi0uMTguNC0uNDAzLjRIMS44NjZjLS4yMiAwLS40MDMtLjE4LS40MDMtLjR2LTEuMjAzaDM0LjI4MmMuNjUgMCAxLjE4LS41MjQgMS4xOC0xLjE3M1YxMC40NjdoLjgwNWMuNDQ2IDAgLjgwNi4zNi44MDYuOHoiIHN0cm9rZT0iIzZGNzg5MyIgZmlsbD0iIzZGNzg5MyIgb3BhY2l0eT0iLjE1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTEuNTM4IDE2LjI4aDIwLjE0OGMuMjIyIDAgLjQwMy4xOC40MDMuNHY2LjUyN2MwIC4yMjItLjE4Mi40LS40MDQuNEgxMS41MzhjLS4yMjMgMC0uNDA0LS4xNzgtLjQwNC0uNFYxNi42OGMwLS4yMi4xOC0uNC40MDQtLjQiIGZpbGw9IiNFNkU3RTgiLz48cGF0aCBkPSJNMTEuNTM4IDE2LjI4aDIwLjE0OGMuMjIyIDAgLjQwMy4xOC40MDMuNHY2LjUyN2MwIC4yMjItLjE4Mi40LS40MDQuNEgxMS41MzhjLS4yMjMgMC0uNDA0LS4xNzgtLjQwNC0uNFYxNi42OGMwLS4yMi4xOC0uNC40MDQtLjR6IiBzdHJva2U9IiM2Rjc4OTMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zMi4wOSAxNi4yOHY2LjkyN2MwIC4yMjItLjE4LjQtLjQwNC40aC0yMC4xNWMtLjIyIDAtLjQtLjE4LS40LS40di0xLjJoMTguMTZjLjY1MyAwIDEuMTgtLjUyNiAxLjE4LTEuMTc0VjE2LjI4aDEuNjEzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjNkU3NzkyIiBvcGFjaXR5PSIuMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zMC40NzcgMTYuMjhoLTMuMjI0di0xLjYwNGMwLS4yMjMuMTgzLS40MDQuNDA3LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xOC40MDcuNDA0djEuNjAzek0xNS45NyAxNi4yOGgtMy4yMjR2LTEuNjA0YzAtLjIyMy4xODItLjQwNC40MDctLjQwNGgyLjQxYy4yMjYgMCAuNDA3LjE4LjQwNy40MDR2MS42MDN6TTI1LjY0IDE2LjI4SDIyLjQydi0xLjYwNGMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xOC40MDcuNDA0djEuNjAzek0yMC44MDYgMTYuMjhoLTMuMjI0di0xLjYwNGMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRIMjAuNGMuMjI0IDAgLjQwNi4xOC40MDYuNDA0djEuNjAzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTguNTU3IDE5LjkxYzAgMS4wMjUtLjgzNyAxLjg1Ny0xLjg3IDEuODU3LTEuMDMgMC0xLjg2Ny0uODMyLTEuODY3LTEuODU4IDAtMS4wMjcuODM3LTEuODU4IDEuODY4LTEuODU4IDEuMDMyIDAgMS44Ny44MyAxLjg3IDEuODU3ek0yMy40OCAxOS45MWMwIDEuMDI1LS44MzYgMS44NTctMS44NjggMS44NTdzLTEuODctLjgzMi0xLjg3LTEuODU4YzAtMS4wMjcuODM4LTEuODU4IDEuODctMS44NThzMS44NjguODMgMS44NjggMS44NTd6TTI4LjQwNCAxOS45MWMwIDEuMDI1LS44MzcgMS44NTctMS44NjggMS44NTctMS4wMzIgMC0xLjg3LS44MzItMS44Ny0xLjg1OCAwLTEuMDI3LjgzOC0xLjg1OCAxLjg3LTEuODU4IDEuMDMgMCAxLjg2OC44MyAxLjg2OCAxLjg1N3oiIHN0cm9rZT0iIzZGNzg5MyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE4LjU1NyAxOS45MjJjMCAxLjAyNi0uODM3IDEuODU4LTEuODcgMS44NTgtMS4wMyAwLTEuODY3LS44MzItMS44NjctMS44NTggMC0xLjAyNS44MzctMS44NTcgMS44NjgtMS44NTcgMS4wMzIgMCAxLjg3LjgzMiAxLjg3IDEuODU3TTIzLjQ4IDE5LjkyMmMwIDEuMDI2LS44MzYgMS44NTgtMS44NjggMS44NThzLTEuODctLjgzMi0xLjg3LTEuODU4YzAtMS4wMjUuODM4LTEuODU3IDEuODctMS44NTdzMS44NjguODMyIDEuODY4IDEuODU3TTI4LjQwNCAxOS45MjJjMCAxLjAyNi0uODM3IDEuODU4LTEuODY4IDEuODU4LTEuMDMyIDAtMS44Ny0uODMyLTEuODctMS44NTggMC0xLjAyNS44MzgtMS44NTcgMS44Ny0xLjg1NyAxLjAzIDAgMS44NjguODMyIDEuODY4IDEuODU3IiBmaWxsPSIjNkY3ODkzIiBvcGFjaXR5PSIuNSIvPjwvZz48L3N2Zz4='; + +/** + * Manage power, direction, and timers for one WeDo 2.0 motor. + */ +class WeDo2Motor { + /** + * Construct a WeDo2Motor instance. + * @param {WeDo2} parent - the WeDo 2.0 device which owns this motor. + * @param {int} index - the zero-based index of this motor on its parent device. + */ + constructor (parent, index) { + /** + * The WeDo 2.0 device which owns this motor. + * @type {WeDo2} + * @private + */ + this._parent = parent; + + /** + * The zero-based index of this motor on its parent device. + * @type {int} + * @private + */ + this._index = index; + + /** + * This motor's current direction: 1 for "this way" or -1 for "that way" + * @type {number} + * @private + */ + this._direction = 1; + + /** + * This motor's current power level, in the range [0,100]. + * @type {number} + * @private + */ + this._power = 100; + + /** + * Is this motor currently moving? + * @type {boolean} + * @private + */ + this._isOn = false; + + /** + * If the motor has been turned on or is actively braking for a specific duration, this is the timeout ID for + * the end-of-action handler. Cancel this when changing plans. + * @type {Object} + * @private + */ + this._pendingTimeoutId = null; + + this.startBraking = this.startBraking.bind(this); + this.setMotorOff = this.setMotorOff.bind(this); + } + + /** + * @return {number} - the duration of active braking after a call to startBraking(). Afterward, turn the motor off. + * @constructor + */ + static get BRAKE_TIME_MS () { + return 1000; + } + + /** + * @return {int} - this motor's current direction: 1 for "this way" or -1 for "that way" + */ + get direction () { + return this._direction; + } + + /** + * @param {int} value - this motor's new direction: 1 for "this way" or -1 for "that way" + */ + set direction (value) { + if (value < 0) { + this._direction = -1; + } else { + this._direction = 1; + } + } + + /** + * @return {int} - this motor's current power level, in the range [0,100]. + */ + get power () { + return this._power; + } + + /** + * @param {int} value - this motor's new power level, in the range [0,100]. + */ + set power (value) { + this._power = Math.max(0, Math.min(value, 100)); + } + + /** + * @return {boolean} - true if this motor is currently moving, false if this motor is off or braking. + */ + get isOn () { + return this._isOn; + } + + /** + * Turn this motor on indefinitely. + */ + setMotorOn () { + this._parent._send('motorOn', {motorIndex: this._index, power: this._direction * this._power}); + this._isOn = true; + this._clearTimeout(); + } + + /** + * Turn this motor on for a specific duration. + * @param {number} milliseconds - run the motor for this long. + */ + setMotorOnFor (milliseconds) { + milliseconds = Math.max(0, milliseconds); + this.setMotorOn(); + this._setNewTimeout(this.startBraking, milliseconds); + } + + /** + * Start active braking on this motor. After a short time, the motor will turn off. + */ + startBraking () { + this._parent._send('motorBrake', {motorIndex: this._index}); + this._isOn = false; + this._setNewTimeout(this.setMotorOff, WeDo2Motor.BRAKE_TIME_MS); + } + + /** + * Turn this motor off. + */ + setMotorOff () { + this._parent._send('motorOff', {motorIndex: this._index}); + this._isOn = false; + } + + /** + * Clear the motor action timeout, if any. Safe to call even when there is no pending timeout. + * @private + */ + _clearTimeout () { + if (this._pendingTimeoutId !== null) { + clearTimeout(this._pendingTimeoutId); + this._pendingTimeoutId = null; + } + } + + /** + * Set a new motor action timeout, after clearing an existing one if necessary. + * @param {Function} callback - to be called at the end of the timeout. + * @param {int} delay - wait this many milliseconds before calling the callback. + * @private + */ + _setNewTimeout (callback, delay) { + this._clearTimeout(); + const timeoutID = setTimeout(() => { + if (this._pendingTimeoutId === timeoutID) { + this._pendingTimeoutId = null; + } + callback(); + }, delay); + this._pendingTimeoutId = timeoutID; + } +} + +/** + * Manage communication with a WeDo 2.0 device over a Device Manager client socket. + */ +class WeDo2 { + + /** + * @return {string} - the type of Device Manager device socket that this class will handle. + */ + static get DEVICE_TYPE () { + return 'wedo2'; + } + + /** + * Construct a WeDo2 communication object. + * @param {Socket} socket - the socket for a WeDo 2.0 device, as provided by a Device Manager client. + */ + constructor (socket) { + /** + * The socket-IO socket used to communicate with the Device Manager about this device. + * @type {Socket} + * @private + */ + this._socket = socket; + + /** + * The motors which this WeDo 2.0 could possibly have. + * @type {[WeDo2Motor]} + * @private + */ + this._motors = [new WeDo2Motor(this, 0), new WeDo2Motor(this, 1)]; + + /** + * The most recently received value for each sensor. + * @type {Object.} + * @private + */ + this._sensors = { + tiltX: 0, + tiltY: 0, + distance: 0 + }; + + this._onSensorChanged = this._onSensorChanged.bind(this); + this._onDisconnect = this._onDisconnect.bind(this); + + this._connectEvents(); + } + + /** + * Manually dispose of this object. + */ + dispose () { + this._disconnectEvents(); + } + + /** + * @return {number} - the latest value received for the tilt sensor's tilt about the X axis. + */ + get tiltX () { + return this._sensors.tiltX; + } + + /** + * @return {number} - the latest value received for the tilt sensor's tilt about the Y axis. + */ + get tiltY () { + return this._sensors.tiltY; + } + + /** + * @return {number} - the latest value received from the distance sensor. + */ + get distance () { + return this._sensors.distance * 10; + } + + /** + * Access a particular motor on this device. + * @param {int} index - the zero-based index of the desired motor. + * @return {WeDo2Motor} - the WeDo2Motor instance, if any, at that index. + */ + motor (index) { + return this._motors[index]; + } + + /** + * Set the WeDo 2.0 hub's LED to a specific color. + * @param {int} rgb - a 24-bit RGB color in 0xRRGGBB format. + */ + setLED (rgb) { + this._send('setLED', {rgb}); + } + + /** + * Play a tone from the WeDo 2.0 hub for a specific amount of time. + * @param {int} tone - the pitch of the tone, in Hz. + * @param {int} milliseconds - the duration of the note, in milliseconds. + */ + playTone (tone, milliseconds) { + this._send('playTone', {tone, ms: milliseconds}); + } + + /** + * Stop the tone playing from the WeDo 2.0 hub, if any. + */ + stopTone () { + this._send('stopTone'); + } + + /** + * Attach event handlers to the device socket. + * @private + */ + _connectEvents () { + this._socket.on('sensorChanged', this._onSensorChanged); + this._socket.on('deviceWasClosed', this._onDisconnect); + this._socket.on('disconnect', this._onDisconnect); + } + + /** + * Detach event handlers from the device socket. + * @private + */ + _disconnectEvents () { + this._socket.off('sensorChanged', this._onSensorChanged); + this._socket.off('deviceWasClosed', this._onDisconnect); + this._socket.off('disconnect', this._onDisconnect); + } + + /** + * Store the sensor value from an incoming 'sensorChanged' event. + * @param {object} event - the 'sensorChanged' event. + * @property {string} sensorName - the name of the sensor which changed. + * @property {number} sensorValue - the new value of the sensor. + * @private + */ + _onSensorChanged (event) { + this._sensors[event.sensorName] = event.sensorValue; + } + + /** + * React to device disconnection. May be called more than once. + * @private + */ + _onDisconnect () { + this._disconnectEvents(); + } + + /** + * Send a message to the device socket. + * @param {string} message - the name of the message, such as 'playTone'. + * @param {object} [details] - optional additional details for the message, such as tone duration and pitch. + * @private + */ + _send (message, details) { + this._socket.emit(message, details); + } +} + +/** + * Enum for motor specification. + * @readonly + * @enum {string} + */ +const MotorID = { + DEFAULT: 'motor', + A: 'motor A', + B: 'motor B', + ALL: 'all motors' +}; + +/** + * Enum for motor direction specification. + * @readonly + * @enum {string} + */ +const MotorDirection = { + FORWARD: 'this way', + BACKWARD: 'that way', + REVERSE: 'reverse' +}; + +/** + * Enum for tilt sensor direction. + * @readonly + * @enum {string} + */ +const TiltDirection = { + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right', + ANY: 'any' +}; + +/** + * Scratch 3.0 blocks to interact with a LEGO WeDo 2.0 device. + */ +class Scratch3WeDo2Blocks { + + /** + * @return {string} - the ID of this extension. + */ + static get EXTENSION_ID () { + return 'wedo2'; + } + + /** + * @return {number} - the tilt sensor counts as "tilted" if its tilt angle meets or exceeds this threshold. + */ + static get TILT_THRESHOLD () { + return 15; + } + + /** + * Construct a set of WeDo 2.0 blocks. + * @param {Runtime} runtime - the Scratch 3.0 runtime. + */ + constructor (runtime) { + /** + * The Scratch 3.0 runtime. + * @type {Runtime} + */ + this.runtime = runtime; + + this.connect(); + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: Scratch3WeDo2Blocks.EXTENSION_ID, + name: 'WeDo 2.0', + iconURI: iconURI, + blocks: [ + { + opcode: 'motorOnFor', + text: 'turn [MOTOR_ID] on for [DURATION] seconds', + blockType: BlockType.COMMAND, + arguments: { + MOTOR_ID: { + type: ArgumentType.STRING, + menu: 'motorID', + defaultValue: MotorID.DEFAULT + }, + DURATION: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'motorOn', + text: 'turn [MOTOR_ID] on', + blockType: BlockType.COMMAND, + arguments: { + MOTOR_ID: { + type: ArgumentType.STRING, + menu: 'motorID', + defaultValue: MotorID.DEFAULT + } + } + }, + { + opcode: 'motorOff', + text: 'turn [MOTOR_ID] off', + blockType: BlockType.COMMAND, + arguments: { + MOTOR_ID: { + type: ArgumentType.STRING, + menu: 'motorID', + defaultValue: MotorID.DEFAULT + } + } + }, + { + opcode: 'startMotorPower', + text: 'set [MOTOR_ID] power to [POWER]', + blockType: BlockType.COMMAND, + arguments: { + MOTOR_ID: { + type: ArgumentType.STRING, + menu: 'motorID', + defaultValue: MotorID.DEFAULT + }, + POWER: { + type: ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setMotorDirection', + text: 'set [MOTOR_ID] direction to [DIRECTION]', + blockType: BlockType.COMMAND, + arguments: { + MOTOR_ID: { + type: ArgumentType.STRING, + menu: 'motorID', + defaultValue: MotorID.DEFAULT + }, + DIRECTION: { + type: ArgumentType.STRING, + menu: 'motorDirection', + defaultValue: MotorDirection.FORWARD + } + } + }, + { + opcode: 'setLightHue', + text: 'set light color to [HUE]', + blockType: BlockType.COMMAND, + arguments: { + HUE: { + type: ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'playNoteFor', + text: 'play note [NOTE] for [DURATION] seconds', + blockType: BlockType.COMMAND, + arguments: { + NOTE: { + type: ArgumentType.NUMBER, // TODO: ArgumentType.MIDI_NOTE? + defaultValue: 60 + }, + DURATION: { + type: ArgumentType.NUMBER, + defaultValue: 0.5 + } + } + }, + { + opcode: 'whenDistance', + text: 'when distance [OP] [REFERENCE]', + blockType: BlockType.HAT, + arguments: { + OP: { + type: ArgumentType.STRING, + menu: 'lessMore', + defaultValue: '<' + }, + REFERENCE: { + type: ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'whenTilted', + text: 'when tilted [DIRECTION]', + func: 'isTilted', + blockType: BlockType.HAT, + arguments: { + DIRECTION: { + type: ArgumentType.STRING, + menu: 'tiltDirectionAny', + defaultValue: TiltDirection.ANY + } + } + }, + { + opcode: 'getDistance', + text: 'distance', + blockType: BlockType.REPORTER + }, + { + opcode: 'isTilted', + text: 'tilted [DIRECTION]?', + blockType: BlockType.BOOLEAN, + arguments: { + DIRECTION: { + type: ArgumentType.STRING, + menu: 'tiltDirectionAny', + defaultValue: TiltDirection.ANY + } + } + }, + { + opcode: 'getTiltAngle', + text: 'tilt angle [DIRECTION]', + blockType: BlockType.REPORTER, + arguments: { + DIRECTION: { + type: ArgumentType.STRING, + menu: 'tiltDirection', + defaultValue: TiltDirection.UP + } + } + } + ], + menus: { + motorID: [MotorID.DEFAULT, MotorID.A, MotorID.B, MotorID.ALL], + motorDirection: [MotorDirection.FORWARD, MotorDirection.BACKWARD, MotorDirection.REVERSE], + tiltDirection: [TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT], + tiltDirectionAny: + [TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT, TiltDirection.ANY], + lessMore: ['<', '>'] + } + }; + } + + /** + * Use the Device Manager client to attempt to connect to a WeDo 2.0 device. + */ + connect () { + if (this._device || this._finder) { + return; + } + const deviceManager = this.runtime.ioDevices.deviceManager; + const finder = this._finder = + deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_ID, WeDo2.DEVICE_TYPE); + this._finder.promise.then( + socket => { + if (this._finder === finder) { + this._finder = null; + this._device = new WeDo2(socket); + } else { + log.warn('Ignoring success from stale WeDo 2.0 connection attempt'); + } + }, + reason => { + if (this._finder === finder) { + this._finder = null; + log.warn(`WeDo 2.0 connection failed: ${reason}`); + } else { + log.warn('Ignoring failure from stale WeDo 2.0 connection attempt'); + } + }); + } + + /** + * Turn specified motor(s) on for a specified duration. + * @param {object} args - the block's arguments. + * @property {MotorID} MOTOR_ID - the motor(s) to activate. + * @property {int} DURATION - the amount of time to run the motors. + * @return {Promise} - a promise which will resolve at the end of the duration. + */ + motorOnFor (args) { + const durationMS = args.DURATION * 1000; + return new Promise(resolve => { + this._forEachMotor(args.MOTOR_ID, motorIndex => { + this._device.motor(motorIndex).setMotorOnFor(durationMS); + }); + + // Ensure this block runs for a fixed amount of time even when no device is connected. + setTimeout(resolve, durationMS); + }); + } + + /** + * Turn specified motor(s) on indefinitely. + * @param {object} args - the block's arguments. + * @property {MotorID} MOTOR_ID - the motor(s) to activate. + */ + motorOn (args) { + this._forEachMotor(args.MOTOR_ID, motorIndex => { + this._device.motor(motorIndex).setMotorOn(); + }); + } + + /** + * Turn specified motor(s) off. + * @param {object} args - the block's arguments. + * @property {MotorID} MOTOR_ID - the motor(s) to deactivate. + */ + motorOff (args) { + this._forEachMotor(args.MOTOR_ID, motorIndex => { + this._device.motor(motorIndex).setMotorOff(); + }); + } + + /** + * Turn specified motor(s) off. + * @param {object} args - the block's arguments. + * @property {MotorID} MOTOR_ID - the motor(s) to be affected. + * @property {int} POWER - the new power level for the motor(s). + */ + startMotorPower (args) { + this._forEachMotor(args.MOTOR_ID, motorIndex => { + const motor = this._device.motor(motorIndex); + motor.power = args.POWER; + motor.setMotorOn(); + }); + } + + /** + * Set the direction of rotation for specified motor(s). + * If the direction is 'reverse' the motor(s) will be reversed individually. + * @param {object} args - the block's arguments. + * @property {MotorID} MOTOR_ID - the motor(s) to be affected. + * @property {MotorDirection} DIRECTION - the new direction for the motor(s). + */ + setMotorDirection (args) { + this._forEachMotor(args.MOTOR_ID, motorIndex => { + const motor = this._device.motor(motorIndex); + switch (args.DIRECTION) { + case MotorDirection.FORWARD: + motor.direction = 1; + break; + case MotorDirection.BACKWARD: + motor.direction = -1; + break; + case MotorDirection.REVERSE: + motor.direction = -motor.direction; + break; + default: + log.warn(`Unknown motor direction in setMotorDirection: ${args.DIRECTION}`); + break; + } + }); + } + + /** + * Set the LED's hue. + * @param {object} args - the block's arguments. + * @property {number} HUE - the hue to set, in the range [0,100]. + */ + setLightHue (args) { + // Convert from [0,100] to [0,360] + const hue = args.HUE * 360 / 100; + + const rgbObject = color.hsvToRgb({h: hue, s: 1, v: 1}); + + const rgbDecimal = color.rgbToDecimal(rgbObject); + + this._device.setLED(rgbDecimal); + } + + /** + * Make the WeDo 2.0 hub play a MIDI note for the specified duration. + * @param {object} args - the block's arguments. + * @property {number} NOTE - the MIDI note to play. + * @property {number} DURATION - the duration of the note, in seconds. + * @return {Promise} - a promise which will resolve at the end of the duration. + */ + playNoteFor (args) { + return new Promise(resolve => { + const durationMS = args.DURATION * 1000; + const tone = this._noteToTone(args.NOTE); + this._device.playTone(tone, durationMS); + + // Ensure this block runs for a fixed amount of time even when no device is connected. + setTimeout(resolve, durationMS); + }); + } + + /** + * Compare the distance sensor's value to a reference. + * @param {object} args - the block's arguments. + * @property {string} OP - the comparison operation: '<' or '>'. + * @property {number} REFERENCE - the value to compare against. + * @return {boolean} - the result of the comparison, or false on error. + */ + whenDistance (args) { + switch (args.OP) { + case '<': + case '<': + return this._device.distance < args.REFERENCE; + case '>': + case '>': + return this._device.distance > args.REFERENCE; + default: + log.warn(`Unknown comparison operator in whenDistance: ${args.OP}`); + return false; + } + } + + /** + * Test whether the tilt sensor is currently tilted. + * @param {object} args - the block's arguments. + * @property {TiltDirection} DIRECTION - the tilt direction to test (up, down, left, right, or any). + * @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction. + */ + whenTilted (args) { + return this._isTilted(args.DIRECTION); + } + + /** + * @return {number} - the distance sensor's value, scaled to the [0,100] range. + */ + getDistance () { + return this._device.distance; + } + + /** + * Test whether the tilt sensor is currently tilted. + * @param {object} args - the block's arguments. + * @property {TiltDirection} DIRECTION - the tilt direction to test (up, down, left, right, or any). + * @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction. + */ + isTilted (args) { + return this._isTilted(args.DIRECTION); + } + + /** + * @param {object} args - the block's arguments. + * @property {TiltDirection} DIRECTION - the direction (up, down, left, right) to check. + * @return {number} - the tilt sensor's angle in the specified direction. + * Note that getTiltAngle(up) = -getTiltAngle(down) and getTiltAngle(left) = -getTiltAngle(right). + */ + getTiltAngle (args) { + return this._getTiltAngle(args.DIRECTION); + } + + /** + * Test whether the tilt sensor is currently tilted. + * @param {TiltDirection} direction - the tilt direction to test (up, down, left, right, or any). + * @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction. + * @private + */ + _isTilted (direction) { + switch (direction) { + case TiltDirection.ANY: + return (Math.abs(this._device.tiltX) >= Scratch3WeDo2Blocks.TILT_THRESHOLD) || + (Math.abs(this._device.tiltY) >= Scratch3WeDo2Blocks.TILT_THRESHOLD); + default: + return this._getTiltAngle(direction) >= Scratch3WeDo2Blocks.TILT_THRESHOLD; + } + } + + /** + * @param {TiltDirection} direction - the direction (up, down, left, right) to check. + * @return {number} - the tilt sensor's angle in the specified direction. + * Note that getTiltAngle(up) = -getTiltAngle(down) and getTiltAngle(left) = -getTiltAngle(right). + * @private + */ + _getTiltAngle (direction) { + switch (direction) { + case TiltDirection.UP: + return -this._device.tiltY; + case TiltDirection.DOWN: + return this._device.tiltY; + case TiltDirection.LEFT: + return -this._device.tiltX; + case TiltDirection.RIGHT: + return this._device.tiltX; + default: + log.warn(`Unknown tilt direction in _getTiltAngle: ${direction}`); + } + } + + /** + * Call a callback for each motor indexed by the provided motor ID. + * @param {MotorID} motorID - the ID specifier. + * @param {Function} callback - the function to call with the numeric motor index for each motor. + * @private + */ + _forEachMotor (motorID, callback) { + let motors; + switch (motorID) { + case MotorID.A: + motors = [0]; + break; + case MotorID.B: + motors = [1]; + break; + case MotorID.ALL: + case MotorID.DEFAULT: + motors = [0, 1]; + break; + default: + log.warn(`Invalid motor ID: ${motorID}`); + motors = []; + break; + } + for (const index of motors) { + callback(index); + } + } + + /** + * @param {number} midiNote - the MIDI note value to convert. + * @return {number} - the frequency, in Hz, corresponding to that MIDI note value. + * @private + */ + _noteToTone (midiNote) { + // Note that MIDI note 69 is A4, 440 Hz + return 440 * Math.pow(2, (midiNote - 69) / 12); + } +} + +module.exports = Scratch3WeDo2Blocks; diff --git a/src/import/load-costume.js b/src/import/load-costume.js new file mode 100644 index 00000000..a355eddd --- /dev/null +++ b/src/import/load-costume.js @@ -0,0 +1,90 @@ +const StringUtil = require('../util/string-util'); +const log = require('../util/log'); + +/** + * Initialize a costume from an asset asynchronously. + * Do not call this unless there is a renderer attached. + * @param {!object} costume - the Scratch costume object. + * @property {int} skinId - the ID of the costume's render skin, once installed. + * @property {number} rotationCenterX - the X component of the costume's origin. + * @property {number} rotationCenterY - the Y component of the costume's origin. + * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. + * @param {!Asset} costumeAsset - the asset of the costume loaded from storage. + * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @returns {?Promise} - a promise which will resolve after skinId is set, or null on error. + */ +const loadCostumeFromAsset = function (costume, costumeAsset, runtime) { + costume.assetId = costumeAsset.assetId; + if (!runtime.renderer) { + log.error('No rendering module present; cannot load costume: ', costume.name); + return costume; + } + const AssetType = runtime.storage.AssetType; + const rotationCenter = [ + costume.rotationCenterX / costume.bitmapResolution, + costume.rotationCenterY / costume.bitmapResolution + ]; + if (costumeAsset.assetType === AssetType.ImageVector) { + costume.skinId = runtime.renderer.createSVGSkin(costumeAsset.decodeText(), rotationCenter); + return costume; + } + + return new Promise((resolve, reject) => { + const imageElement = new Image(); + const onError = function () { + // eslint-disable-next-line no-use-before-define + removeEventListeners(); + reject(); + }; + const onLoad = function () { + // eslint-disable-next-line no-use-before-define + removeEventListeners(); + resolve(imageElement); + }; + const removeEventListeners = function () { + imageElement.removeEventListener('error', onError); + imageElement.removeEventListener('load', onLoad); + }; + imageElement.addEventListener('error', onError); + imageElement.addEventListener('load', onLoad); + imageElement.src = costumeAsset.encodeDataURI(); + }).then(imageElement => { + costume.skinId = runtime.renderer.createBitmapSkin(imageElement, costume.bitmapResolution, rotationCenter); + return costume; + }); +}; + +/** + * Load a costume's asset into memory asynchronously. + * Do not call this unless there is a renderer attached. + * @param {string} md5ext - the MD5 and extension of the costume to be loaded. + * @param {!object} costume - the Scratch costume object. + * @property {int} skinId - the ID of the costume's render skin, once installed. + * @property {number} rotationCenterX - the X component of the costume's origin. + * @property {number} rotationCenterY - the Y component of the costume's origin. + * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. + * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @returns {?Promise} - a promise which will resolve after skinId is set, or null on error. + */ +const loadCostume = function (md5ext, costume, runtime) { + if (!runtime.storage) { + log.error('No storage module present; cannot load costume asset: ', md5ext); + return Promise.resolve(costume); + } + + const AssetType = runtime.storage.AssetType; + const idParts = StringUtil.splitFirst(md5ext, '.'); + const md5 = idParts[0]; + const ext = idParts[1].toLowerCase(); + const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap; + + return runtime.storage.load(assetType, md5, ext).then(costumeAsset => { + costume.dataFormat = ext; + return loadCostumeFromAsset(costume, costumeAsset, runtime); + }); +}; + +module.exports = { + loadCostume, + loadCostumeFromAsset +}; diff --git a/src/import/load-sound.js b/src/import/load-sound.js new file mode 100644 index 00000000..e4c7009b --- /dev/null +++ b/src/import/load-sound.js @@ -0,0 +1,55 @@ +const StringUtil = require('../util/string-util'); +const log = require('../util/log'); + +/** + * Initialize a sound from an asset asynchronously. + * @param {!object} sound - the Scratch sound object. + * @property {string} md5 - the MD5 and extension of the sound to be loaded. + * @property {Buffer} data - sound data will be written here once loaded. + * @param {!Asset} soundAsset - the asset loaded from storage. + * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @returns {!Promise} - a promise which will resolve to the sound when ready. + */ +const loadSoundFromAsset = function (sound, soundAsset, runtime) { + sound.assetId = soundAsset.assetId; + return runtime.audioEngine.decodeSound(Object.assign( + {}, + sound, + {data: soundAsset.data} + )).then(soundId => { + sound.soundId = soundId; + return sound; + }); +}; + +/** + * Load a sound's asset into memory asynchronously. + * @param {!object} sound - the Scratch sound object. + * @property {string} md5 - the MD5 and extension of the sound to be loaded. + * @property {Buffer} data - sound data will be written here once loaded. + * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @returns {!Promise} - a promise which will resolve to the sound when ready. + */ +const loadSound = function (sound, runtime) { + if (!runtime.storage) { + log.error('No storage module present; cannot load sound asset: ', sound.md5); + return Promise.resolve(sound); + } + if (!runtime.audioEngine) { + log.error('No audio engine present; cannot load sound asset: ', sound.md5); + return Promise.resolve(sound); + } + const idParts = StringUtil.splitFirst(sound.md5, '.'); + const md5 = idParts[0]; + const ext = idParts[1].toLowerCase(); + return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) + .then(soundAsset => { + sound.dataFormat = ext; + return loadSoundFromAsset(sound, soundAsset, runtime); + }); +}; + +module.exports = { + loadSound, + loadSoundFromAsset +}; diff --git a/src/import/sb2import.js b/src/import/sb2import.js deleted file mode 100644 index b97144d2..00000000 --- a/src/import/sb2import.js +++ /dev/null @@ -1,418 +0,0 @@ -/** - * @fileoverview - * Partial implementation of an SB2 JSON importer. - * Parses provided JSON and then generates all needed - * scratch-vm runtime structures. - */ - -var Blocks = require('../engine/blocks'); -var Clone = require('../sprites/clone'); -var Sprite = require('../sprites/sprite'); -var Color = require('../util/color.js'); -var uid = require('../util/uid'); -var specMap = require('./sb2specmap'); -var Variable = require('../engine/variable'); -var List = require('../engine/list'); - -/** - * Top-level handler. Parse provided JSON, - * and process the top-level object (the stage object). - * @param {!string} json SB2-format JSON to load. - * @param {!Runtime} runtime Runtime object to load all structures into. - */ -function sb2import (json, runtime) { - parseScratchObject( - JSON.parse(json), - runtime, - true - ); -} - -/** - * Parse a single "Scratch object" and create all its in-memory VM objects. - * @param {!Object} object From-JSON "Scratch object:" sprite, stage, watcher. - * @param {!Runtime} runtime Runtime object to load all structures into. - * @param {boolean} topLevel Whether this is the top-level object (stage). - */ -function parseScratchObject (object, runtime, topLevel) { - if (!object.hasOwnProperty('objName')) { - // Watcher/monitor - skip this object until those are implemented in VM. - // @todo - return; - } - // Blocks container for this object. - var blocks = new Blocks(); - // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. - var sprite = new Sprite(blocks, runtime); - // Sprite/stage name from JSON. - if (object.hasOwnProperty('objName')) { - sprite.name = object.objName; - } - // Costumes from JSON. - if (object.hasOwnProperty('costumes')) { - for (var i = 0; i < object.costumes.length; i++) { - var costume = object.costumes[i]; - // @todo: Make sure all the relevant metadata is being pulled out. - sprite.costumes.push({ - skin: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/' - + costume.baseLayerMD5 + '/get/', - name: costume.costumeName, - bitmapResolution: costume.bitmapResolution, - rotationCenterX: costume.rotationCenterX, - rotationCenterY: costume.rotationCenterY - }); - } - } - // If included, parse any and all scripts/blocks on the object. - if (object.hasOwnProperty('scripts')) { - parseScripts(object.scripts, blocks); - } - // Create the first clone, and load its run-state from JSON. - var target = sprite.createClone(); - // Add it to the runtime's list of targets. - runtime.targets.push(target); - // Load target properties from JSON. - if (object.hasOwnProperty('variables')) { - for (var j = 0; j < object.variables.length; j++) { - var variable = object.variables[j]; - target.variables[variable.name] = new Variable( - variable.name, - variable.value, - variable.isPersistent - ); - } - } - if (object.hasOwnProperty('lists')) { - for (var k = 0; k < object.lists.length; k++) { - var list = object.lists[k]; - // @todo: monitor properties. - target.lists[list.listName] = new List( - list.listName, - list.contents - ); - } - } - if (object.hasOwnProperty('scratchX')) { - target.x = object.scratchX; - } - if (object.hasOwnProperty('scratchY')) { - target.y = object.scratchY; - } - if (object.hasOwnProperty('direction')) { - target.direction = object.direction; - } - if (object.hasOwnProperty('scale')) { - // SB2 stores as 1.0 = 100%; we use % in the VM. - target.size = object.scale * 100; - } - if (object.hasOwnProperty('visible')) { - target.visible = object.visible; - } - if (object.hasOwnProperty('currentCostumeIndex')) { - target.currentCostume = Math.round(object.currentCostumeIndex); - } - if (object.hasOwnProperty('rotationStyle')) { - if (object.rotationStyle == 'none') { - target.rotationStyle = Clone.ROTATION_STYLE_NONE; - } else if (object.rotationStyle == 'leftRight') { - target.rotationStyle = Clone.ROTATION_STYLE_LEFT_RIGHT; - } else if (object.rotationStyle == 'normal') { - target.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND; - } - } - target.isStage = topLevel; - target.updateAllDrawableProperties(); - // The stage will have child objects; recursively process them. - if (object.children) { - for (var m = 0; m < object.children.length; m++) { - parseScratchObject(object.children[m], runtime, false); - } - } -} - -/** - * Parse a Scratch object's scripts into VM blocks. - * This should only handle top-level scripts that include X, Y coordinates. - * @param {!Object} scripts Scripts object from SB2 JSON. - * @param {!Blocks} blocks Blocks object to load parsed blocks into. - */ -function parseScripts (scripts, blocks) { - for (var i = 0; i < scripts.length; i++) { - var script = scripts[i]; - var scriptX = script[0]; - var scriptY = script[1]; - var blockList = script[2]; - var parsedBlockList = parseBlockList(blockList); - if (parsedBlockList[0]) { - // Adjust script coordinates to account for - // larger block size in scratch-blocks. - // @todo: Determine more precisely the right formulas here. - parsedBlockList[0].x = scriptX * 1.1; - parsedBlockList[0].y = scriptY * 1.1; - parsedBlockList[0].topLevel = true; - parsedBlockList[0].parent = null; - } - // Flatten children and create add the blocks. - var convertedBlocks = flatten(parsedBlockList); - for (var j = 0; j < convertedBlocks.length; j++) { - blocks.createBlock(convertedBlocks[j]); - } - } -} - -/** - * Parse any list of blocks from SB2 JSON into a list of VM-format blocks. - * Could be used to parse a top-level script, - * a list of blocks in a branch (e.g., in forever), - * or a list of blocks in an argument (e.g., move [pick random...]). - * @param {Array.} blockList SB2 JSON-format block list. - * @return {Array.} Scratch VM-format block list. - */ -function parseBlockList (blockList) { - var resultingList = []; - var previousBlock = null; // For setting next. - for (var i = 0; i < blockList.length; i++) { - var block = blockList[i]; - var parsedBlock = parseBlock(block); - if (previousBlock) { - parsedBlock.parent = previousBlock.id; - previousBlock.next = parsedBlock.id; - } - previousBlock = parsedBlock; - resultingList.push(parsedBlock); - } - return resultingList; -} - -/** - * Flatten a block tree into a block list. - * Children are temporarily stored on the `block.children` property. - * @param {Array.} blocks list generated by `parseBlockList`. - * @return {Array.} Flattened list to be passed to `blocks.createBlock`. - */ -function flatten (blocks) { - var finalBlocks = []; - for (var i = 0; i < blocks.length; i++) { - var block = blocks[i]; - finalBlocks.push(block); - if (block.children) { - finalBlocks = finalBlocks.concat(flatten(block.children)); - } - delete block.children; - } - return finalBlocks; -} - -/** - * Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n") - * into an argument map. This allows us to provide the expected inputs - * to a mutated procedure call. - * @param {string} procCode Scratch 2.0 procedure string. - * @return {Object} Argument map compatible with those in sb2specmap. - */ -function parseProcedureArgMap (procCode) { - var argMap = [ - {} // First item in list is op string. - ]; - var INPUT_PREFIX = 'input'; - var inputCount = 0; - // Split by %n, %b, %s. - var parts = procCode.split(/(?=[^\\]\%[nbs])/); - for (var i = 0; i < parts.length; i++) { - var part = parts[i].trim(); - if (part.substring(0, 1) == '%') { - var argType = part.substring(1, 2); - var arg = { - type: 'input', - inputName: INPUT_PREFIX + (inputCount++) - }; - if (argType == 'n') { - arg.inputOp = 'math_number'; - } else if (argType == 's') { - arg.inputOp = 'text'; - } - argMap.push(arg); - } - } - return argMap; -} - -/** - * Parse a single SB2 JSON-formatted block and its children. - * @param {!Object} sb2block SB2 JSON-formatted block. - * @return {Object} Scratch VM format block. - */ -function parseBlock (sb2block) { - // First item in block object is the old opcode (e.g., 'forward:'). - var oldOpcode = sb2block[0]; - // Convert the block using the specMap. See sb2specmap.js. - if (!oldOpcode || !specMap[oldOpcode]) { - console.warn('Couldn\'t find SB2 block: ', oldOpcode); - return; - } - var blockMetadata = specMap[oldOpcode]; - // Block skeleton. - var activeBlock = { - id: uid(), // Generate a new block unique ID. - opcode: blockMetadata.opcode, // Converted, e.g. "motion_movesteps". - inputs: {}, // Inputs to this block and the blocks they point to. - fields: {}, // Fields on this block and their values. - next: null, // Next block. - shadow: false, // No shadow blocks in an SB2 by default. - children: [] // Store any generated children, flattened in `flatten`. - }; - // For a procedure call, generate argument map from proc string. - if (oldOpcode == 'call') { - blockMetadata.argMap = parseProcedureArgMap(sb2block[1]); - } - // Look at the expected arguments in `blockMetadata.argMap.` - // The basic problem here is to turn positional SB2 arguments into - // non-positional named Scratch VM arguments. - for (var i = 0; i < blockMetadata.argMap.length; i++) { - var expectedArg = blockMetadata.argMap[i]; - var providedArg = sb2block[i + 1]; // (i = 0 is opcode) - // Whether the input is obscuring a shadow. - var shadowObscured = false; - // Positional argument is an input. - if (expectedArg.type == 'input') { - // Create a new block and input metadata. - var inputUid = uid(); - activeBlock.inputs[expectedArg.inputName] = { - name: expectedArg.inputName, - block: null, - shadow: null - }; - if (typeof providedArg == 'object' && providedArg) { - // Block or block list occupies the input. - var innerBlocks; - if (typeof providedArg[0] == 'object' && providedArg[0]) { - // Block list occupies the input. - innerBlocks = parseBlockList(providedArg); - } else { - // Single block occupies the input. - innerBlocks = [parseBlock(providedArg)]; - } - var previousBlock = null; - for (var j = 0; j < innerBlocks.length; j++) { - if (j == 0) { - innerBlocks[j].parent = activeBlock.id; - } else { - innerBlocks[j].parent = previousBlock; - } - previousBlock = innerBlocks[j].id; - } - // Obscures any shadow. - shadowObscured = true; - activeBlock.inputs[expectedArg.inputName].block = ( - innerBlocks[0].id - ); - activeBlock.children = ( - activeBlock.children.concat(innerBlocks) - ); - } - // Generate a shadow block to occupy the input. - if (!expectedArg.inputOp) { - // No editable shadow input; e.g., for a boolean. - continue; - } - // Each shadow has a field generated for it automatically. - // Value to be filled in the field. - var fieldValue = providedArg; - // Shadows' field names match the input name, except for these: - var fieldName = expectedArg.inputName; - if (expectedArg.inputOp == 'math_number' || - expectedArg.inputOp == 'math_whole_number' || - expectedArg.inputOp == 'math_positive_number' || - expectedArg.inputOp == 'math_integer' || - expectedArg.inputOp == 'math_angle') { - fieldName = 'NUM'; - // Fields are given Scratch 2.0 default values if obscured. - if (shadowObscured) { - fieldValue = 10; - } - } else if (expectedArg.inputOp == 'text') { - fieldName = 'TEXT'; - if (shadowObscured) { - fieldValue = ''; - } - } else if (expectedArg.inputOp == 'colour_picker') { - // Convert SB2 color to hex. - fieldValue = Color.decimalToHex(providedArg); - fieldName = 'COLOUR'; - if (shadowObscured) { - fieldValue = '#990000'; - } - } - var fields = {}; - fields[fieldName] = { - name: fieldName, - value: fieldValue - }; - activeBlock.children.push({ - id: inputUid, - opcode: expectedArg.inputOp, - inputs: {}, - fields: fields, - next: null, - topLevel: false, - parent: activeBlock.id, - shadow: true - }); - activeBlock.inputs[expectedArg.inputName].shadow = inputUid; - // If no block occupying the input, alias to the shadow. - if (!activeBlock.inputs[expectedArg.inputName].block) { - activeBlock.inputs[expectedArg.inputName].block = inputUid; - } - } else if (expectedArg.type == 'field') { - // Add as a field on this block. - activeBlock.fields[expectedArg.fieldName] = { - name: expectedArg.fieldName, - value: providedArg - }; - } - } - // Special cases to generate mutations. - if (oldOpcode == 'stopScripts') { - // Mutation for stop block: if the argument is 'other scripts', - // the block needs a next connection. - if (sb2block[1] == 'other scripts in sprite' || - sb2block[1] == 'other scripts in stage') { - activeBlock.mutation = { - tagName: 'mutation', - hasnext: 'true', - children: [] - }; - } - } else if (oldOpcode == 'procDef') { - // Mutation for procedure definition: - // store all 2.0 proc data. - var procData = sb2block.slice(1); - activeBlock.mutation = { - tagName: 'mutation', - proccode: procData[0], // e.g., "abc %n %b %s" - argumentnames: JSON.stringify(procData[1]), // e.g. ['arg1', 'arg2'] - argumentdefaults: JSON.stringify(procData[2]), // e.g., [1, 'abc'] - warp: procData[3], // Warp mode, e.g., true/false. - children: [] - }; - } else if (oldOpcode == 'call') { - // Mutation for procedure call: - // string for proc code (e.g., "abc %n %b %s"). - activeBlock.mutation = { - tagName: 'mutation', - children: [], - proccode: sb2block[1] - }; - } else if (oldOpcode == 'getParam') { - // Mutation for procedure parameter. - activeBlock.mutation = { - tagName: 'mutation', - children: [], - paramname: sb2block[1], // Name of parameter. - shape: sb2block[2] // Shape - in 2.0, 'r' or 'b'. - }; - } - return activeBlock; -} - -module.exports = sb2import; diff --git a/src/import/sb2specmap.js b/src/import/sb2specmap.js deleted file mode 100644 index 1cf0620f..00000000 --- a/src/import/sb2specmap.js +++ /dev/null @@ -1,1388 +0,0 @@ -/** - * @fileoverview - * The specMap below handles a few pieces of "translation" work between - * the SB2 JSON format and the data we need to run a project - * in the Scratch 3.0 VM. - * Notably: - * - Map 2.0 and 1.4 opcodes (forward:) into 3.0-format (motion_movesteps). - * - Map ordered, unnamed args to unordered, named inputs and fields. - * Keep this up-to-date as 3.0 blocks are renamed, changed, etc. - * Originally this was generated largely by a hand-guided scripting process. - * The relevant data lives here: - * https://github.com/LLK/scratch-flash/blob/master/src/Specs.as - * (for the old opcode and argument order). - * and here: - * https://github.com/LLK/scratch-blocks/tree/develop/blocks_vertical - * (for the new opcodes and argument names). - * and here: - * https://github.com/LLK/scratch-blocks/blob/develop/tests/ - * (for the shadow blocks created for each block). - * I started with the `commands` array in Specs.as, and discarded irrelevant - * properties. By hand, I matched the opcode name to the 3.0 opcode. - * Finally, I filled in the expected arguments as below. - */ -var specMap = { - 'forward:':{ - 'opcode':'motion_movesteps', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'STEPS' - } - ] - }, - 'turnRight:':{ - 'opcode':'motion_turnright', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'DEGREES' - } - ] - }, - 'turnLeft:':{ - 'opcode':'motion_turnleft', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'DEGREES' - } - ] - }, - 'heading:':{ - 'opcode':'motion_pointindirection', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_angle', - 'inputName':'DIRECTION' - } - ] - }, - 'pointTowards:':{ - 'opcode':'motion_pointtowards', - 'argMap':[ - { - 'type':'input', - 'inputOp':'motion_pointtowards_menu', - 'inputName':'TOWARDS' - } - ] - }, - 'gotoX:y:':{ - 'opcode':'motion_gotoxy', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'X' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'Y' - } - ] - }, - 'gotoSpriteOrMouse:':{ - 'opcode':'motion_goto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'motion_goto_menu', - 'inputName':'TO' - } - ] - }, - 'glideSecs:toX:y:elapsed:from:':{ - 'opcode':'motion_glidesecstoxy', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SECS' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'X' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'Y' - } - ] - }, - 'changeXposBy:':{ - 'opcode':'motion_changexby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'DX' - } - ] - }, - 'xpos:':{ - 'opcode':'motion_setx', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'X' - } - ] - }, - 'changeYposBy:':{ - 'opcode':'motion_changeyby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'DY' - } - ] - }, - 'ypos:':{ - 'opcode':'motion_sety', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'Y' - } - ] - }, - 'bounceOffEdge':{ - 'opcode':'motion_ifonedgebounce', - 'argMap':[ - ] - }, - 'setRotationStyle':{ - 'opcode':'motion_setrotationstyle', - 'argMap':[ - { - 'type':'input', - 'inputOp':'motion_setrotationstyle_menu', - 'inputName':'STYLE' - } - ] - }, - 'xpos':{ - 'opcode':'motion_xposition', - 'argMap':[ - ] - }, - 'ypos':{ - 'opcode':'motion_yposition', - 'argMap':[ - ] - }, - 'heading':{ - 'opcode':'motion_direction', - 'argMap':[ - ] - }, - 'say:duration:elapsed:from:':{ - 'opcode':'looks_sayforsecs', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'MESSAGE' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SECS' - } - ] - }, - 'say:':{ - 'opcode':'looks_say', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'MESSAGE' - } - ] - }, - 'think:duration:elapsed:from:':{ - 'opcode':'looks_thinkforsecs', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'MESSAGE' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SECS' - } - ] - }, - 'think:':{ - 'opcode':'looks_think', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'MESSAGE' - } - ] - }, - 'show':{ - 'opcode':'looks_show', - 'argMap':[ - ] - }, - 'hide':{ - 'opcode':'looks_hide', - 'argMap':[ - ] - }, - 'lookLike:':{ - 'opcode':'looks_switchcostumeto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'looks_costume', - 'inputName':'COSTUME' - } - ] - }, - 'nextCostume':{ - 'opcode':'looks_nextcostume', - 'argMap':[ - ] - }, - 'startScene':{ - 'opcode':'looks_switchbackdropto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'looks_backdrops', - 'inputName':'BACKDROP' - } - ] - }, - 'changeGraphicEffect:by:':{ - 'opcode':'looks_changeeffectby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'looks_effectmenu', - 'inputName':'EFFECT' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'CHANGE' - } - ] - }, - 'setGraphicEffect:to:':{ - 'opcode':'looks_seteffectto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'looks_effectmenu', - 'inputName':'EFFECT' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'VALUE' - } - ] - }, - 'filterReset':{ - 'opcode':'looks_cleargraphiceffects', - 'argMap':[ - ] - }, - 'changeSizeBy:':{ - 'opcode':'looks_changesizeby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'CHANGE' - } - ] - }, - 'setSizeTo:':{ - 'opcode':'looks_setsizeto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SIZE' - } - ] - }, - 'comeToFront':{ - 'opcode':'looks_gotofront', - 'argMap':[ - ] - }, - 'goBackByLayers:':{ - 'opcode':'looks_gobacklayers', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_integer', - 'inputName':'NUM' - } - ] - }, - 'costumeIndex':{ - 'opcode':'looks_costumeorder', - 'argMap':[ - ] - }, - 'sceneName':{ - 'opcode':'looks_backdropname', - 'argMap':[ - ] - }, - 'scale':{ - 'opcode':'looks_size', - 'argMap':[ - ] - }, - 'startSceneAndWait':{ - 'opcode':'looks_switchbackdroptoandwait', - 'argMap':[ - { - 'type':'input', - 'inputOp':'looks_backdrops', - 'inputName':'BACKDROP' - } - ] - }, - 'nextScene':{ - 'opcode':'looks_nextbackdrop', - 'argMap':[ - ] - }, - 'backgroundIndex':{ - 'opcode':'looks_backdroporder', - 'argMap':[ - ] - }, - 'playSound:':{ - 'opcode':'sound_play', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sound_sounds_option', - 'inputName':'SOUND_MENU' - } - ] - }, - 'doPlaySoundAndWait':{ - 'opcode':'sound_playuntildone', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sound_sounds_option', - 'inputName':'SOUND_MENU' - } - ] - }, - 'stopAllSounds':{ - 'opcode':'sound_stopallsounds', - 'argMap':[ - ] - }, - 'playDrum':{ - 'opcode':'sound_playdrumforbeats', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'DRUMTYPE' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'BEATS' - } - ] - }, - 'rest:elapsed:from:':{ - 'opcode':'sound_restforbeats', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'BEATS' - } - ] - }, - 'noteOn:duration:elapsed:from:':{ - 'opcode':'sound_playnoteforbeats', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NOTE' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'BEATS' - } - ] - }, - 'instrument:':{ - 'opcode':'sound_setinstrumentto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'INSTRUMENT' - } - ] - }, - 'changeVolumeBy:':{ - 'opcode':'sound_changevolumeby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'VOLUME' - } - ] - }, - 'setVolumeTo:':{ - 'opcode':'sound_setvolumeto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'VOLUME' - } - ] - }, - 'volume':{ - 'opcode':'sound_volume', - 'argMap':[ - ] - }, - 'changeTempoBy:':{ - 'opcode':'sound_changetempoby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'TEMPO' - } - ] - }, - 'setTempoTo:':{ - 'opcode':'sound_settempotobpm', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'TEMPO' - } - ] - }, - 'tempo':{ - 'opcode':'sound_tempo', - 'argMap':[ - ] - }, - 'clearPenTrails':{ - 'opcode':'pen_clear', - 'argMap':[ - ] - }, - 'stampCostume':{ - 'opcode':'pen_stamp', - 'argMap':[ - ] - }, - 'putPenDown':{ - 'opcode':'pen_pendown', - 'argMap':[ - ] - }, - 'putPenUp':{ - 'opcode':'pen_penup', - 'argMap':[ - ] - }, - 'penColor:':{ - 'opcode':'pen_setpencolortocolor', - 'argMap':[ - { - 'type':'input', - 'inputOp':'colour_picker', - 'inputName':'COLOR' - } - ] - }, - 'changePenHueBy:':{ - 'opcode':'pen_changepencolorby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'COLOR' - } - ] - }, - 'setPenHueTo:':{ - 'opcode':'pen_setpencolortonum', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'COLOR' - } - ] - }, - 'changePenShadeBy:':{ - 'opcode':'pen_changepenshadeby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SHADE' - } - ] - }, - 'setPenShadeTo:':{ - 'opcode':'pen_changepenshadeby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SHADE' - } - ] - }, - 'changePenSizeBy:':{ - 'opcode':'pen_changepensizeby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SIZE' - } - ] - }, - 'penSize:':{ - 'opcode':'pen_setpensizeto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'SIZE' - } - ] - }, - 'whenGreenFlag':{ - 'opcode':'event_whenflagclicked', - 'argMap':[ - ] - }, - 'whenKeyPressed':{ - 'opcode':'event_whenkeypressed', - 'argMap':[ - { - 'type':'field', - 'fieldName':'KEY_OPTION' - } - ] - }, - 'whenClicked':{ - 'opcode':'event_whenthisspriteclicked', - 'argMap':[ - ] - }, - 'whenSceneStarts':{ - 'opcode':'event_whenbackdropswitchesto', - 'argMap':[ - { - 'type':'field', - 'fieldName':'BACKDROP' - } - ] - }, - 'whenSensorGreaterThan':{ - 'opcode':'event_whengreaterthan', - 'argMap':[ - { - 'type':'field', - 'fieldName':'WHENGREATERTHANMENU' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'VALUE' - } - ] - }, - 'whenIReceive':{ - 'opcode':'event_whenbroadcastreceived', - 'argMap':[ - { - 'type':'field', - 'fieldName':'BROADCAST_OPTION' - } - ] - }, - 'broadcast:':{ - 'opcode':'event_broadcast', - 'argMap':[ - { - 'type':'input', - 'inputOp':'event_broadcast_menu', - 'inputName':'BROADCAST_OPTION' - } - ] - }, - 'doBroadcastAndWait':{ - 'opcode':'event_broadcastandwait', - 'argMap':[ - { - 'type':'input', - 'inputOp':'event_broadcast_menu', - 'inputName':'BROADCAST_OPTION' - } - ] - }, - 'wait:elapsed:from:':{ - 'opcode':'control_wait', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_positive_number', - 'inputName':'DURATION' - } - ] - }, - 'doRepeat':{ - 'opcode':'control_repeat', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_whole_number', - 'inputName':'TIMES' - }, - { - 'type':'input', - 'inputName': 'SUBSTACK' - } - ] - }, - 'doForever':{ - 'opcode':'control_forever', - 'argMap':[ - { - 'type':'input', - 'inputName':'SUBSTACK' - } - ] - }, - 'doIf':{ - 'opcode':'control_if', - 'argMap':[ - { - 'type':'input', - 'inputName':'CONDITION' - }, - { - 'type':'input', - 'inputName':'SUBSTACK' - } - ] - }, - 'doIfElse':{ - 'opcode':'control_if_else', - 'argMap':[ - { - 'type':'input', - 'inputName':'CONDITION' - }, - { - 'type':'input', - 'inputName':'SUBSTACK' - }, - { - 'type':'input', - 'inputName':'SUBSTACK2' - } - ] - }, - 'doWaitUntil':{ - 'opcode':'control_wait_until', - 'argMap':[ - { - 'type':'input', - 'inputName':'CONDITION' - } - ] - }, - 'doUntil':{ - 'opcode':'control_repeat_until', - 'argMap':[ - { - 'type':'input', - 'inputName':'CONDITION' - }, - { - 'type':'input', - 'inputName':'SUBSTACK' - } - ] - }, - 'stopScripts':{ - 'opcode':'control_stop', - 'argMap':[ - { - 'type':'field', - 'fieldName':'STOP_OPTION' - } - ] - }, - 'whenCloned':{ - 'opcode':'control_start_as_clone', - 'argMap':[ - ] - }, - 'createCloneOf':{ - 'opcode':'control_create_clone_of', - 'argMap':[ - { - 'type':'input', - 'inputOp':'control_create_clone_of_menu', - 'inputName':'CLONE_OPTION' - } - ] - }, - 'deleteClone':{ - 'opcode':'control_delete_this_clone', - 'argMap':[ - ] - }, - 'touching:':{ - 'opcode':'sensing_touchingobject', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_touchingobjectmenu', - 'inputName':'TOUCHINGOBJECTMENU' - } - ] - }, - 'touchingColor:':{ - 'opcode':'sensing_touchingcolor', - 'argMap':[ - { - 'type':'input', - 'inputOp':'colour_picker', - 'inputName':'COLOR' - } - ] - }, - 'color:sees:':{ - 'opcode':'sensing_coloristouchingcolor', - 'argMap':[ - { - 'type':'input', - 'inputOp':'colour_picker', - 'inputName':'COLOR' - }, - { - 'type':'input', - 'inputOp':'colour_picker', - 'inputName':'COLOR2' - } - ] - }, - 'distanceTo:':{ - 'opcode':'sensing_distanceto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_distancetomenu', - 'inputName':'DISTANCETOMENU' - } - ] - }, - 'doAsk':{ - 'opcode':'sensing_askandwait', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'QUESTION' - } - ] - }, - 'answer':{ - 'opcode':'sensing_answer', - 'argMap':[ - ] - }, - 'keyPressed:':{ - 'opcode':'sensing_keypressed', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_keyoptions', - 'inputName':'KEY_OPTION' - } - ] - }, - 'mousePressed':{ - 'opcode':'sensing_mousedown', - 'argMap':[ - ] - }, - 'mouseX':{ - 'opcode':'sensing_mousex', - 'argMap':[ - ] - }, - 'mouseY':{ - 'opcode':'sensing_mousey', - 'argMap':[ - ] - }, - 'soundLevel':{ - 'opcode':'sensing_loudness', - 'argMap':[ - ] - }, - 'senseVideoMotion':{ - 'opcode':'sensing_videoon', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_videoonmenuone', - 'inputName':'VIDEOONMENU1' - }, - { - 'type':'input', - 'inputOp':'sensing_videoonmenutwo', - 'inputName':'VIDEOONMENU2' - } - ] - }, - 'setVideoState':{ - 'opcode':'sensing_videotoggle', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_videotogglemenu', - 'inputName':'VIDEOTOGGLEMENU' - } - ] - }, - 'setVideoTransparency':{ - 'opcode':'sensing_setvideotransparency', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'TRANSPARENCY' - } - ] - }, - 'timer':{ - 'opcode':'sensing_timer', - 'argMap':[ - ] - }, - 'timerReset':{ - 'opcode':'sensing_resettimer', - 'argMap':[ - ] - }, - 'getAttribute:of:':{ - 'opcode':'sensing_of', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_of_property_menu', - 'inputName':'PROPERTY' - }, - { - 'type':'input', - 'inputOp':'sensing_of_object_menu', - 'inputName':'OBJECT' - } - ] - }, - 'timeAndDate':{ - 'opcode':'sensing_current', - 'argMap':[ - { - 'type':'input', - 'inputOp':'sensing_currentmenu', - 'inputName':'CURRENTMENU' - } - ] - }, - 'timestamp':{ - 'opcode':'sensing_dayssince2000', - 'argMap':[ - ] - }, - 'getUserName':{ - 'opcode':'sensing_username', - 'argMap':[ - ] - }, - '+':{ - 'opcode':'operator_add', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM1' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM2' - } - ] - }, - '-':{ - 'opcode':'operator_subtract', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM1' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM2' - } - ] - }, - '*':{ - 'opcode':'operator_multiply', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM1' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM2' - } - ] - }, - '/':{ - 'opcode':'operator_divide', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM1' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM2' - } - ] - }, - 'randomFrom:to:':{ - 'opcode':'operator_random', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'FROM' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'TO' - } - ] - }, - '<':{ - 'opcode':'operator_lt', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND1' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND2' - } - ] - }, - '=':{ - 'opcode':'operator_equals', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND1' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND2' - } - ] - }, - '>':{ - 'opcode':'operator_gt', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND1' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'OPERAND2' - } - ] - }, - '&':{ - 'opcode':'operator_and', - 'argMap':[ - { - 'type':'input', - 'inputName':'OPERAND1' - }, - { - 'type':'input', - 'inputName':'OPERAND2' - } - ] - }, - '|':{ - 'opcode':'operator_or', - 'argMap':[ - { - 'type':'input', - 'inputName':'OPERAND1' - }, - { - 'type':'input', - 'inputName':'OPERAND2' - } - ] - }, - 'not':{ - 'opcode':'operator_not', - 'argMap':[ - { - 'type':'input', - 'inputName':'OPERAND' - } - ] - }, - 'concatenate:with:':{ - 'opcode':'operator_join', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'STRING1' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'STRING2' - } - ] - }, - 'letter:of:':{ - 'opcode':'operator_letter_of', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_whole_number', - 'inputName':'LETTER' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'STRING' - } - ] - }, - 'stringLength:':{ - 'opcode':'operator_length', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'STRING' - } - ] - }, - '%':{ - 'opcode':'operator_mod', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM1' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM2' - } - ] - }, - 'rounded':{ - 'opcode':'operator_round', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM' - } - ] - }, - 'computeFunction:of:':{ - 'opcode':'operator_mathop', - 'argMap':[ - { - 'type':'input', - 'inputOp':'operator_mathop_menu', - 'inputName':'OPERATOR' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'NUM' - } - ] - }, - 'readVariable':{ - 'opcode':'data_variable', - 'argMap':[ - { - 'type':'input', - 'inputOp':'data_variablemenu', - 'inputName':'VARIABLE' - } - ] - }, - 'setVar:to:':{ - 'opcode':'data_setvariableto', - 'argMap':[ - { - 'type':'input', - 'inputOp':'data_variablemenu', - 'inputName':'VARIABLE' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'VALUE' - } - ] - }, - 'changeVar:by:':{ - 'opcode':'data_changevariableby', - 'argMap':[ - { - 'type':'input', - 'inputOp':'data_variablemenu', - 'inputName':'VARIABLE' - }, - { - 'type':'input', - 'inputOp':'math_number', - 'inputName':'VALUE' - } - ] - }, - 'showVariable:':{ - 'opcode':'data_showvariable', - 'argMap':[ - { - 'type':'input', - 'inputOp':'data_variablemenu', - 'inputName':'VARIABLE' - } - ] - }, - 'hideVariable:':{ - 'opcode':'data_hidevariable', - 'argMap':[ - { - 'type':'input', - 'inputOp':'data_variablemenu', - 'inputName':'VARIABLE' - } - ] - }, - 'contentsOfList:':{ - 'opcode':'data_list', - 'argMap':[ - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'append:toList:':{ - 'opcode':'data_addtolist', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'ITEM' - }, - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'deleteLine:ofList:':{ - 'opcode':'data_deleteoflist', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_integer', - 'inputName':'INDEX' - }, - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'insert:at:ofList:':{ - 'opcode':'data_insertatlist', - 'argMap':[ - { - 'type':'input', - 'inputOp':'text', - 'inputName':'ITEM' - }, - { - 'type':'input', - 'inputOp':'math_integer', - 'inputName':'INDEX' - }, - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'setLine:ofList:to:':{ - 'opcode':'data_replaceitemoflist', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_integer', - 'inputName':'INDEX' - }, - { - 'type':'field', - 'fieldName':'LIST' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'ITEM' - } - ] - }, - 'getLine:ofList:':{ - 'opcode':'data_itemoflist', - 'argMap':[ - { - 'type':'input', - 'inputOp':'math_integer', - 'inputName':'INDEX' - }, - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'lineCountOfList:':{ - 'opcode':'data_lengthoflist', - 'argMap':[ - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'list:contains:':{ - 'opcode':'data_listcontainsitem', - 'argMap':[ - { - 'type':'field', - 'fieldName':'LIST' - }, - { - 'type':'input', - 'inputOp':'text', - 'inputName':'ITEM' - } - ] - }, - 'showList:':{ - 'opcode':'data_showlist', - 'argMap':[ - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'hideList:':{ - 'opcode':'data_hidelist', - 'argMap':[ - { - 'type':'field', - 'fieldName':'LIST' - } - ] - }, - 'procDef':{ - 'opcode':'procedures_defnoreturn', - 'argMap':[] - }, - 'getParam':{ - 'opcode':'procedures_param', - 'argMap':[] - }, - 'call':{ - 'opcode':'procedures_callnoreturn', - 'argMap':[] - } -}; -module.exports = specMap; diff --git a/src/index.js b/src/index.js index 014dc2b2..dfb56632 100644 --- a/src/index.js +++ b/src/index.js @@ -1,311 +1,3 @@ -var EventEmitter = require('events'); -var util = require('util'); - -var Runtime = require('./engine/runtime'); -var sb2import = require('./import/sb2import'); -var Sprite = require('./sprites/sprite'); -var Blocks = require('./engine/blocks'); - -/** - * Handles connections between blocks, stage, and extensions. - * - * @author Andrew Sliwinski - */ -function VirtualMachine () { - var instance = this; - // Bind event emitter and runtime to VM instance - EventEmitter.call(instance); - /** - * VM runtime, to store blocks, I/O devices, sprites/targets, etc. - * @type {!Runtime} - */ - instance.runtime = new Runtime(); - /** - * The "currently editing"/selected target ID for the VM. - * Block events from any Blockly workspace are routed to this target. - * @type {!string} - */ - instance.editingTarget = null; - // Runtime emits are passed along as VM emits. - instance.runtime.on(Runtime.SCRIPT_GLOW_ON, function (id) { - instance.emit(Runtime.SCRIPT_GLOW_ON, {id: id}); - }); - instance.runtime.on(Runtime.SCRIPT_GLOW_OFF, function (id) { - instance.emit(Runtime.SCRIPT_GLOW_OFF, {id: id}); - }); - instance.runtime.on(Runtime.BLOCK_GLOW_ON, function (id) { - instance.emit(Runtime.BLOCK_GLOW_ON, {id: id}); - }); - instance.runtime.on(Runtime.BLOCK_GLOW_OFF, function (id) { - instance.emit(Runtime.BLOCK_GLOW_OFF, {id: id}); - }); - instance.runtime.on(Runtime.VISUAL_REPORT, function (id, value) { - instance.emit(Runtime.VISUAL_REPORT, {id: id, value: value}); - }); - - this.blockListener = this.blockListener.bind(this); - this.flyoutBlockListener = this.flyoutBlockListener.bind(this); -} - -/** - * Inherit from EventEmitter - */ -util.inherits(VirtualMachine, EventEmitter); - -/** - * Start running the VM - do this before anything else. - */ -VirtualMachine.prototype.start = function () { - this.runtime.start(); -}; - -/** - * "Green flag" handler - start all threads starting with a green flag. - */ -VirtualMachine.prototype.greenFlag = function () { - this.runtime.greenFlag(); -}; - -/** - * Set whether the VM is in "turbo mode." - * When true, loops don't yield to redraw. - * @param {Boolean} turboModeOn Whether turbo mode should be set. - */ -VirtualMachine.prototype.setTurboMode = function (turboModeOn) { - this.runtime.turboMode = !!turboModeOn; -}; - -/** - * Set whether the VM is in "pause mode." - * When true, nothing is stepped. - * @param {Boolean} pauseModeOn Whether pause mode should be set. - */ -VirtualMachine.prototype.setPauseMode = function (pauseModeOn) { - this.runtime.setPauseMode(!!pauseModeOn); -}; - -/** - * Set whether the VM is in 2.0 "compatibility mode." - * When true, ticks go at 2.0 speed (30 TPS). - * @param {Boolean} compatibilityModeOn Whether compatibility mode is set. - */ -VirtualMachine.prototype.setCompatibilityMode = function (compatibilityModeOn) { - this.runtime.setCompatibilityMode(!!compatibilityModeOn); -}; - -/** - * Set whether the VM is in "single stepping mode." - * When true, blocks execute slowly and are highlighted visually. - * @param {Boolean} singleSteppingOn Whether single-stepping mode is set. - */ -VirtualMachine.prototype.setSingleSteppingMode = function (singleSteppingOn) { - this.runtime.setSingleSteppingMode(!!singleSteppingOn); -}; - - -/** - * Set single-stepping mode speed. - * When in single-stepping mode, adjusts the speed of execution. - * @param {Number} speed Interval length in ms. - */ -VirtualMachine.prototype.setSingleSteppingSpeed = function (speed) { - this.runtime.setSingleSteppingSpeed(speed); -}; - - -/** - * Stop all threads and running activities. - */ -VirtualMachine.prototype.stopAll = function () { - this.runtime.stopAll(); -}; - -/** - * Clear out current running project data. - */ -VirtualMachine.prototype.clear = function () { - this.runtime.dispose(); - this.editingTarget = null; - this.emitTargetsUpdate(); -}; - -/** - * Get data for playground. Data comes back in an emitted event. - */ -VirtualMachine.prototype.getPlaygroundData = function () { - var instance = this; - // Only send back thread data for the current editingTarget. - var threadData = this.runtime.threads.filter(function(thread) { - return thread.target == instance.editingTarget; - }); - // Remove the target key, since it's a circular reference. - var filteredThreadData = JSON.stringify(threadData, function(key, value) { - if (key == 'target') return undefined; - return value; - }, 2); - this.emit('playgroundData', { - blocks: this.editingTarget.blocks, - threads: filteredThreadData - }); -}; - -/** - * Handle an animation frame. - */ -VirtualMachine.prototype.animationFrame = function () { - this.runtime.animationFrame(); -}; - -/** - * Post I/O data to the virtual devices. - * @param {?string} device Name of virtual I/O device. - * @param {Object} data Any data object to post to the I/O device. - */ -VirtualMachine.prototype.postIOData = function (device, data) { - if (this.runtime.ioDevices[device]) { - this.runtime.ioDevices[device].postData(data); - } -}; - -/** - * Load a project from a Scratch 2.0 JSON representation. - * @param {?string} json JSON string representing the project. - */ -VirtualMachine.prototype.loadProject = function (json) { - this.clear(); - // @todo: Handle other formats, e.g., Scratch 1.4, Scratch 3.0. - sb2import(json, this.runtime); - // Select the first target for editing, e.g., the stage. - this.editingTarget = this.runtime.targets[0]; - // Update the VM user's knowledge of targets and blocks on the workspace. - this.emitTargetsUpdate(); - this.emitWorkspaceUpdate(); - this.runtime.setEditingTarget(this.editingTarget); -}; - -/** - * Temporary way to make an empty project, in case the desired project - * cannot be loaded from the online server. - */ -VirtualMachine.prototype.createEmptyProject = function () { - // Stage. - var blocks2 = new Blocks(); - var stage = new Sprite(blocks2, this.runtime); - stage.name = 'Stage'; - stage.costumes.push({ - skin: './assets/stage.png', - name: 'backdrop1', - bitmapResolution: 2, - rotationCenterX: 480, - rotationCenterY: 360 - }); - var target2 = stage.createClone(); - this.runtime.targets.push(target2); - target2.x = 0; - target2.y = 0; - target2.direction = 90; - target2.size = 200; - target2.visible = true; - target2.isStage = true; - // Sprite1 (cat). - var blocks1 = new Blocks(); - var sprite = new Sprite(blocks1, this.runtime); - sprite.name = 'Sprite1'; - sprite.costumes.push({ - skin: './assets/scratch_cat.svg', - name: 'costume1', - bitmapResolution: 1, - rotationCenterX: 47, - rotationCenterY: 55 - }); - var target1 = sprite.createClone(); - this.runtime.targets.push(target1); - target1.x = 0; - target1.y = 0; - target1.direction = 90; - target1.size = 100; - target1.visible = true; - this.editingTarget = this.runtime.targets[0]; - this.emitTargetsUpdate(); - this.emitWorkspaceUpdate(); -}; - -/** - * Set the renderer for the VM/runtime - * @param {!RenderWebGL} renderer The renderer to attach - */ -VirtualMachine.prototype.attachRenderer = function (renderer) { - this.runtime.attachRenderer(renderer); -}; - -/** - * Handle a Blockly event for the current editing target. - * @param {!Blockly.Event} e Any Blockly event. - */ -VirtualMachine.prototype.blockListener = function (e) { - if (this.editingTarget) { - this.editingTarget.blocks.blocklyListen(e, this.runtime); - } -}; - -/** - * Handle a Blockly event for the flyout. - * @param {!Blockly.Event} e Any Blockly event. - */ -VirtualMachine.prototype.flyoutBlockListener = function (e) { - this.runtime.flyoutBlocks.blocklyListen(e, this.runtime); -}; - -/** - * Set an editing target. An editor UI can use this function to switch - * between editing different targets, sprites, etc. - * After switching the editing target, the VM may emit updates - * to the list of targets and any attached workspace blocks - * (see `emitTargetsUpdate` and `emitWorkspaceUpdate`). - * @param {string} targetId Id of target to set as editing. - */ -VirtualMachine.prototype.setEditingTarget = function (targetId) { - // Has the target id changed? If not, exit. - if (targetId == this.editingTarget.id) { - return; - } - var target = this.runtime.getTargetById(targetId); - if (target) { - this.editingTarget = target; - // Emit appropriate UI updates. - this.emitTargetsUpdate(); - this.emitWorkspaceUpdate(); - this.runtime.setEditingTarget(target); - } -}; - -/** - * Emit metadata about available targets. - * An editor UI could use this to display a list of targets and show - * the currently editing one. - */ -VirtualMachine.prototype.emitTargetsUpdate = function () { - this.emit('targetsUpdate', { - // [[target id, human readable target name], ...]. - targetList: this.runtime.targets.filter(function (target) { - // Don't report clones. - return !target.hasOwnProperty('isOriginal') || target.isOriginal; - }).map(function(target) { - return [target.id, target.getName()]; - }), - // Currently editing target id. - editingTarget: this.editingTarget ? this.editingTarget.id : null - }); -}; - -/** - * Emit an Blockly/scratch-blocks compatible XML representation - * of the current editing target's blocks. - */ -VirtualMachine.prototype.emitWorkspaceUpdate = function () { - this.emit('workspaceUpdate', { - 'xml': this.editingTarget.blocks.toXML() - }); -}; +const VirtualMachine = require('./virtual-machine'); module.exports = VirtualMachine; diff --git a/src/io/clock.js b/src/io/clock.js index ce4c8f55..cbb9f242 100644 --- a/src/io/clock.js +++ b/src/io/clock.js @@ -1,37 +1,39 @@ -var Timer = require('../util/timer'); +const Timer = require('../util/timer'); -function Clock (runtime) { - this._projectTimer = new Timer(); - this._projectTimer.start(); - this._pausedTime = null; - this._paused = false; - /** - * Reference to the owning Runtime. - * @type{!Runtime} - */ - this.runtime = runtime; +class Clock { + constructor (runtime) { + this._projectTimer = new Timer(); + this._projectTimer.start(); + this._pausedTime = null; + this._paused = false; + /** + * Reference to the owning Runtime. + * @type{!Runtime} + */ + this.runtime = runtime; + } + + projectTimer () { + if (this._paused) { + return this._pausedTime / 1000; + } + return this._projectTimer.timeElapsed() / 1000; + } + + pause () { + this._paused = true; + this._pausedTime = this._projectTimer.timeElapsed(); + } + + resume () { + this._paused = false; + const dt = this._projectTimer.timeElapsed() - this._pausedTime; + this._projectTimer.startTime += dt; + } + + resetProjectTimer () { + this._projectTimer.start(); + } } -Clock.prototype.projectTimer = function () { - if (this._paused) { - return this._pausedTime / 1000; - } - return this._projectTimer.timeElapsed() / 1000; -}; - -Clock.prototype.pause = function () { - this._paused = true; - this._pausedTime = this._projectTimer.timeElapsed(); -}; - -Clock.prototype.resume = function () { - this._paused = false; - var dt = this._projectTimer.timeElapsed() - this._pausedTime; - this._projectTimer.startTime += dt; -}; - -Clock.prototype.resetProjectTimer = function () { - this._projectTimer.start(); -}; - module.exports = Clock; diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js new file mode 100644 index 00000000..9f7075e5 --- /dev/null +++ b/src/io/deviceManager.js @@ -0,0 +1,343 @@ +const nets = require('nets'); +const io = require('socket.io-client/dist/socket.io'); +const querystring = require('querystring'); + +/** + * Internal class used by the Device Manager client to manage making a connection to a particular device. + */ +class DeviceOpener { + /** + * @return {number} - The number of milliseconds to allow before deciding a connection attempt has timed out. + */ + static get CONNECTION_TIMEOUT_MS () { + return 10 * 1000; + } + + /** + * Construct a DeviceOpener to help connect to a particular device. + * @param {DeviceManager} deviceManager - the Device Manager client which instigated this action. + * @param {function} resolve - callback to be called if the device is successfully found, connected, and opened. + * @param {function} reject - callback to be called if an error or timeout is encountered. + */ + constructor (deviceManager, resolve, reject) { + /** + * The DeviceManager client which wants to open a device. + * @type {DeviceManager} + * @private + */ + this._deviceManager = deviceManager; + + /** + * Callback to be called if the device is successfully found, connected, and opened. + * @type {Function} + * @private + */ + this._resolve = resolve; + + /** + * Callback to be called if an error or timeout is encountered. + * @type {Function} + * @private + */ + this._reject = reject; + + /** + * The socket for the device being opened. + * @type {Socket} + * @private + */ + this._socket = null; + + /** + * If this timeout expires before a successful connection, the connection attempt will be canceled. + * @type {Object} + * @private + */ + this._connectionTimeout = null; + } + + /** + * Attempt to open a particular device. This will cause `resolve` or `reject` to be called. + * Note that in some cases it's possible that both `resolve` and `reject` will be called. In that event, ignore all + * calls after the first. If `resolve` and `reject` are from a Promise, then the Promise will do this for you. + * @param {string} extensionName - human-readable name of the extension requesting the device + * @param {string} deviceType - the type of device to open, such as 'wedo2' + * @param {string} deviceId - the ID of the particular device to open, usually from list results + */ + open (extensionName, deviceType, deviceId) { + this._socket = io(`${this._deviceManager._serverURL}/${deviceType}`); + + this._socket.on('deviceWasOpened', () => this.onDeviceWasOpened()); + this._socket.on('disconnect', () => this.onDisconnect()); + this._connectionTimeout = setTimeout(() => this.onTimeout(), DeviceOpener.CONNECTION_TIMEOUT_MS); + + this._socket.emit('open', {deviceId: deviceId, name: extensionName}); + } + + /** + * React to a 'deviceWasOpened' message from the Device Manager application. + */ + onDeviceWasOpened () { + this.clearConnectionTimeout(); + this._resolve(this._socket); + } + + /** + * React to the socket becoming disconnected. + */ + onDisconnect () { + this.clearConnectionTimeout(); + this._reject('device disconnected'); + } + + /** + * React to the connection timeout expiring. This could mean that the socket itself timed out, or that the Device + * Manager took too long to send a 'deviceWasOpened' message back. + */ + onTimeout () { + this.clearConnectionTimeout(); + + // `socket.disconnect()` triggers `onDisconnect` only for connected sockets + if (this._socket.connected) { + this._socket.disconnect(); + } else { + this._reject('connection attempt timed out'); + } + } + + /** + * Cancel the connection timeout. + */ + clearConnectionTimeout () { + if (this._connectionTimeout !== null) { + clearTimeout(this._connectionTimeout); + this._connectionTimeout = null; + } + } +} + +/** + * A DeviceFinder implements the Device Manager client's `searchAndConnect` functionality. + * Use the `promise` property to access a promise for a device socket. + * Call `cancel()` to cancel the search. Once the search finds a device it cannot be canceled. + */ +class DeviceFinder { + /** + * @return {number} - the number of milliseconds to wait between search attempts (calls to 'list') + */ + static get SEARCH_RETRY_MS () { + return 1000; + } + + /** + * Construct a DeviceFinder to help find and connect to a device satisfying specific conditions. + * @param {DeviceManager} deviceManager - the Device Manager client which instigated this action. + * @param {string} extensionName - human-readable name of the extension requesting the search + * @param {string} deviceType - the type of device to find, such as 'wedo2'. + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + */ + constructor (deviceManager, extensionName, deviceType, deviceSpec) { + /** + * The Device Manager client which wants to find a device. + * @type {DeviceManager} + * @private + */ + this._deviceManager = deviceManager; + + /** + * The human-readable name of the extension requesting the search. + * @type {string} + * @private + */ + this._extensionName = extensionName; + + /** + * The type of device to find, such as 'wedo2'. + * @type {string} + * @private + */ + this._deviceType = deviceType; + + /** + * Optional additional information about the specific devices to list. + * @type {Object} + * @private + */ + this._deviceSpec = deviceSpec; + + /** + * Flag indicating that the search should be canceled. + * @type {boolean} + * @private + */ + this._cancel = false; + + /** + * The promise representing this search's results. + * @type {Promise} + * @private + */ + this._promise = null; + + /** + * The fulfillment function for `this._promise`. + * @type {Function} + * @private + */ + this._fulfill = null; + } + + /** + * @return {Promise} - A promise for a device socket. + */ + get promise () { + return this._promise; + } + + /** + * Start searching for a device. + */ + start () { + this._promise = new Promise((fulfill, reject) => { + this._fulfill = fulfill; + this._reject = reject; + this._getList(); + }); + } + + /** + * Cancel the search for a device. Effective only before the promise resolves. + */ + cancel () { + this._cancel = true; + this._reject('canceled'); + } + + /** + * Fetch the list of devices matching the parameters provided in the constructor. + * @private + */ + _getList () { + this._deviceManager + .list(this._extensionName, this._deviceType, this._deviceSpec) + .then( + listResult => this._listResultHandler(listResult), + () => this._listResultHandler(null) + ); + } + + /** + * Handle the list of devices returned by the Device Manager. + * @param {Array} listResult - an array of device information objects. + * @private + */ + _listResultHandler (listResult) { + if (this._cancel) { + return; + } + + if (listResult && listResult.length > 0) { + for (const deviceInfo of listResult) { + if (!deviceInfo.connected) { + this._fulfill(this._deviceManager.open(this._extensionName, this._deviceType, deviceInfo.id)); + return; + } + } + } + + setTimeout(() => this._getList(), DeviceFinder.SEARCH_RETRY_MS); + } +} + +/** + * A Scratch 3.0 "I/O Device" representing a client for the Scratch Device Manager. + */ +class DeviceManager { + /** + * @return {string} - The default Scratch Device Manager connection URL. + */ + static get DEFAULT_SERVER_URL () { + return 'https://device-manager.scratch.mit.edu:3030'; + } + + constructor () { + /** + * The URL this client will use for Device Manager communication both HTTP(S) and WS(S). + * @type {string} + * @private + */ + this._serverURL = DeviceManager.DEFAULT_SERVER_URL; + + /** + * True if there is no known problem connecting to the Scratch Device Manager, false otherwise. + * @type {boolean} + * @private + */ + this._isConnected = true; + } + + /** + * @return {boolean} - True if there is no known problem connecting to the Scratch Device Manager, false otherwise. + */ + get isConnected () { + return this._isConnected; + } + + /** + * High-level request to find and connect to a device satisfying the specified characteristics. + * This function will repeatedly call list() until the list is non-empty, then it will open() the first suitable + * item in the list and provide the socket for that device. + * @todo Offer a way to filter results. See the Scratch 2.0 PicoBoard extension for details on why that's important. + * @param {string} extensionName - human-readable name of the extension requesting the search + * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + * @return {DeviceFinder} - An object providing a Promise for an opened device and a way to cancel the search. + */ + searchAndConnect (extensionName, deviceType, deviceSpec) { + const finder = new DeviceFinder(this, extensionName, deviceType, deviceSpec); + finder.start(); + return finder; + } + + /** + * Request a list of available devices. + * @param {string} extensionName - human-readable name of the extension requesting the list + * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + * @return {Promise} - A Promise for an Array of available devices. + */ + list (extensionName, deviceType, deviceSpec) { + const queryObject = { + name: extensionName + }; + if (deviceSpec) queryObject.spec = deviceSpec; + const url = `${this._serverURL}/${encodeURIComponent(deviceType)}/list?${querystring.stringify(queryObject)}`; + return new Promise((resolve, reject) => { + nets({ + method: 'GET', + url: url, + json: {} + }, (err, res, body) => { + if (err) return reject(err); + if (res.statusCode !== 200) return reject(body); + resolve(body); + }); + }); + } + + /** + * Attempt to open a particular device. + * @param {string} extensionName - human-readable name of the extension requesting the device + * @param {string} deviceType - the type of device to open, such as 'wedo2' + * @param {string} deviceId - the ID of the particular device to open, usually from list results + * @return {Promise} - A Promise for a Socket which can be used to communicate with the device + */ + open (extensionName, deviceType, deviceId) { + return new Promise((resolve, reject) => { + const opener = new DeviceOpener(this, resolve, reject); + opener.open(extensionName, deviceType, deviceId); + }); + } +} + +module.exports = DeviceManager; diff --git a/src/io/keyboard.js b/src/io/keyboard.js index ea423fd4..4ff5ecc5 100644 --- a/src/io/keyboard.js +++ b/src/io/keyboard.js @@ -1,85 +1,103 @@ -var Cast = require('../util/cast'); +const Cast = require('../util/cast'); + +class Keyboard { + constructor (runtime) { + /** + * List of currently pressed keys. + * @type{Array.} + */ + this._keysPressed = []; + /** + * Reference to the owning Runtime. + * Can be used, for example, to activate hats. + * @type{!Runtime} + */ + this.runtime = runtime; + } -function Keyboard (runtime) { /** - * List of currently pressed keys. - * @type{Array.} + * Convert a Scratch key name to a DOM keyCode. + * @param {Any} keyName Scratch key argument. + * @return {number} Key code corresponding to a DOM event. + * @private */ - this._keysPressed = []; + _scratchKeyToKeyCode (keyName) { + if (typeof keyName === 'number') { + // Key codes placed in with number blocks. + return keyName; + } + const keyString = Cast.toString(keyName); + switch (keyString) { + case 'space': return 32; + case 'left arrow': return 37; + case 'up arrow': return 38; + case 'right arrow': return 39; + case 'down arrow': return 40; + // @todo: Consider adding other special keys here. + } + // Keys reported by DOM keyCode are upper case. + return keyString.toUpperCase().charCodeAt(0); + } + /** - * Reference to the owning Runtime. - * Can be used, for example, to activate hats. - * @type{!Runtime} + * Convert a DOM keyCode into a Scratch key name. + * @param {number} keyCode Key code from DOM event. + * @return {Any} Scratch key argument. + * @private */ - this.runtime = runtime; -} + _keyCodeToScratchKey (keyCode) { + if (keyCode >= 48 && keyCode <= 90) { + // Standard letter. + return String.fromCharCode(keyCode).toLowerCase(); + } + switch (keyCode) { + case 32: return 'space'; + case 37: return 'left arrow'; + case 38: return 'up arrow'; + case 39: return 'right arrow'; + case 40: return 'down arrow'; + } + return ''; + } -/** - * Convert a Scratch key name to a DOM keyCode. - * @param {Any} keyName Scratch key argument. - * @return {number} Key code corresponding to a DOM event. - */ -Keyboard.prototype._scratchKeyToKeyCode = function (keyName) { - if (typeof keyName == 'number') { - // Key codes placed in with number blocks. - return keyName; - } - var keyString = Cast.toString(keyName); - switch (keyString) { - case 'space': return 32; - case 'left arrow': return 37; - case 'up arrow': return 38; - case 'right arrow': return 39; - case 'down arrow': return 40; - // @todo: Consider adding other special keys here. - } - // Keys reported by DOM keyCode are upper case. - return keyString.toUpperCase().charCodeAt(0); -}; - -Keyboard.prototype._keyCodeToScratchKey = function (keyCode) { - if (keyCode >= 48 && keyCode <= 90) { - // Standard letter. - return String.fromCharCode(keyCode).toLowerCase(); - } - switch (keyCode) { - case 32: return 'space'; - case 37: return 'left arrow'; - case 38: return 'up arrow'; - case 39: return 'right arrow'; - case 40: return 'down arrow'; - } - return null; -}; - -Keyboard.prototype.postData = function (data) { - if (data.keyCode) { - var index = this._keysPressed.indexOf(data.keyCode); - if (data.isDown) { - // If not already present, add to the list. - if (index < 0) { - this._keysPressed.push(data.keyCode); + /** + * Keyboard DOM event handler. + * @param {object} data Data from DOM event. + */ + postData (data) { + if (data.keyCode) { + const index = this._keysPressed.indexOf(data.keyCode); + if (data.isDown) { + // If not already present, add to the list. + if (index < 0) { + this._keysPressed.push(data.keyCode); + } + // Always trigger hats, even if it was already pressed. + this.runtime.startHats('event_whenkeypressed', { + KEY_OPTION: this._keyCodeToScratchKey(data.keyCode) + }); + this.runtime.startHats('event_whenkeypressed', { + KEY_OPTION: 'any' + }); + } else if (index > -1) { + // If already present, remove from the list. + this._keysPressed.splice(index, 1); } - // Always trigger hats, even if it was already pressed. - this.runtime.startHats('event_whenkeypressed', { - 'KEY_OPTION': this._keyCodeToScratchKey(data.keyCode) - }); - this.runtime.startHats('event_whenkeypressed', { - 'KEY_OPTION': 'any' - }); - } else if (index > -1) { - // If already present, remove from the list. - this._keysPressed.splice(index, 1); } } -}; -Keyboard.prototype.getKeyIsDown = function (key) { - if (key == 'any') { - return this._keysPressed.length > 0; + /** + * Get key down state for a specified Scratch key name. + * @param {Any} key Scratch key argument. + * @return {boolean} Is the specified key down? + */ + getKeyIsDown (key) { + if (key === 'any') { + return this._keysPressed.length > 0; + } + const keyCode = this._scratchKeyToKeyCode(key); + return this._keysPressed.indexOf(keyCode) > -1; } - var keyCode = this._scratchKeyToKeyCode(key); - return this._keysPressed.indexOf(keyCode) > -1; -}; +} module.exports = Keyboard; diff --git a/src/io/mouse.js b/src/io/mouse.js index 6b9860ee..ecff2db1 100644 --- a/src/io/mouse.js +++ b/src/io/mouse.js @@ -1,57 +1,107 @@ -var MathUtil = require('../util/math-util'); +const MathUtil = require('../util/math-util'); + +class Mouse { + constructor (runtime) { + this._x = 0; + this._y = 0; + this._isDown = false; + /** + * Reference to the owning Runtime. + * Can be used, for example, to activate hats. + * @type{!Runtime} + */ + this.runtime = runtime; + } -function Mouse (runtime) { - this._x = 0; - this._y = 0; - this._isDown = false; /** - * Reference to the owning Runtime. - * Can be used, for example, to activate hats. - * @type{!Runtime} + * Activate "event_whenthisspriteclicked" hats if needed. + * @param {number} x X position to be sent to the renderer. + * @param {number} y Y position to be sent to the renderer. + * @private */ - this.runtime = runtime; -} - -Mouse.prototype.postData = function(data) { - if (data.x) { - this._x = data.x - data.canvasWidth / 2; - } - if (data.y) { - this._y = data.y - data.canvasHeight / 2; - } - if (typeof data.isDown !== 'undefined') { - this._isDown = data.isDown; - if (this._isDown) { - this._activateClickHats(data.x, data.y); - } - } -}; - -Mouse.prototype._activateClickHats = function (x, y) { - if (this.runtime.renderer) { - var drawableID = this.runtime.renderer.pick(x, y); - for (var i = 0; i < this.runtime.targets.length; i++) { - var target = this.runtime.targets[i]; - if (target.hasOwnProperty('drawableID') && - target.drawableID == drawableID) { - this.runtime.startHats('event_whenthisspriteclicked', - null, target); - return; + _activateClickHats (x, y) { + if (this.runtime.renderer) { + const drawableID = this.runtime.renderer.pick(x, y); + for (let i = 0; i < this.runtime.targets.length; i++) { + const target = this.runtime.targets[i]; + if (target.hasOwnProperty('drawableID') && + target.drawableID === drawableID) { + this.runtime.startHats('event_whenthisspriteclicked', + null, target); + return; + } } } } -}; -Mouse.prototype.getX = function () { - return MathUtil.clamp(this._x, -240, 240); -}; + /** + * Mouse DOM event handler. + * @param {object} data Data from DOM event. + */ + postData (data) { + if (data.x) { + this._clientX = data.x; + this._scratchX = MathUtil.clamp( + 480 * ((data.x / data.canvasWidth) - 0.5), + -240, + 240 + ); + } + if (data.y) { + this._clientY = data.y; + this._scratchY = MathUtil.clamp( + -360 * ((data.y / data.canvasHeight) - 0.5), + -180, + 180 + ); + } + if (typeof data.isDown !== 'undefined') { + this._isDown = data.isDown; + if (!this._isDown) { + this._activateClickHats(data.x, data.y); + } + } + } -Mouse.prototype.getY = function () { - return MathUtil.clamp(-this._y, -180, 180); -}; + /** + * Get the X position of the mouse in client coordinates. + * @return {number} Non-clamped X position of the mouse cursor. + */ + getClientX () { + return this._clientX; + } -Mouse.prototype.getIsDown = function () { - return this._isDown; -}; + /** + * Get the Y position of the mouse in client coordinates. + * @return {number} Non-clamped Y position of the mouse cursor. + */ + getClientY () { + return this._clientY; + } + + /** + * Get the X position of the mouse in scratch coordinates. + * @return {number} Clamped X position of the mouse cursor. + */ + getScratchX () { + return this._scratchX; + } + + /** + * Get the Y position of the mouse in scratch coordinates. + * @return {number} Clamped Y position of the mouse cursor. + */ + getScratchY () { + return this._scratchY; + } + + /** + * Get the down state of the mouse. + * @return {boolean} Is the mouse down? + */ + getIsDown () { + return this._isDown; + } +} module.exports = Mouse; diff --git a/src/playground/benchmark.css b/src/playground/benchmark.css new file mode 100644 index 00000000..746c173a --- /dev/null +++ b/src/playground/benchmark.css @@ -0,0 +1,83 @@ +body { + font-family: monospace; +} +p { + max-width: 400px; +} +@media (min-width: 960px) { + .profile-tables { + top: 0px; + position: absolute; + left: 450px; + } +} +.share { + display: none; +} +.share label { + cursor: pointer; +} +.share[href] { + display: inline; +} + +.render .profile-tables { + position: static; + left: 0; +} +.render .description { + display: none; +} +.render .run-push { + display: none; +} +#scratch-stage { + border: 5px solid black; + display: block; + width: 400px; + height: 300px; +} +.render #scratch-stage { + display: none; +} +.loading label, .profile-count label{ + width: 15em; + display: inline-block; +} +.render .loading { + display: none; +} +.profile-tables table { + margin: 30px 0 30px 0px; +} +.profile-tables th { + border-bottom: 1px solid #333; + text-align: center; +} +.profile-tables th:first-child { + width: 215px; +} +.profile-tables th, .profile-tables td { + min-width: 85px; + border-bottom: 1px solid #ccc; + border-spacing: 0; + border-collapse: collapse; + padding: 5px; +} +.profile-tables td:not(:first-child) { + text-align: center; +} +.profile-tables img{ + margin: 0 auto; + display: block; + clear: both; + width: 20%; +} + +.slow { + background-color: #ffa1a1; +} +.profiler-count-running { + height: 4em; + background-color: #dddddd; +} diff --git a/src/playground/benchmark.js b/src/playground/benchmark.js new file mode 100644 index 00000000..4e6d383b --- /dev/null +++ b/src/playground/benchmark.js @@ -0,0 +1,574 @@ +const Scratch = window.Scratch = window.Scratch || {}; + +const ASSET_SERVER = 'https://cdn.assets.scratch.mit.edu/'; +const PROJECT_SERVER = 'https://cdn.projects.scratch.mit.edu/'; + +const SLOW = .1; + +const projectInput = document.querySelector('input'); + +document.querySelector('.run') + .addEventListener('click', () => { + window.location.hash = projectInput.value; + location.reload(); + }, false); + +const setShareLink = function (json) { + document.querySelector('.share') + .href = `#view/${btoa(JSON.stringify(json))}`; + document.querySelectorAll('.share')[1] + .href = `suite.html`; +}; + +const loadProject = function () { + let id = location.hash.substring(1).split(',')[0]; + if (id.length < 1 || !isFinite(id)) { + id = projectInput.value; + } + Scratch.vm.downloadProjectId(id); + return id; +}; + +/** + * @param {Asset} asset - calculate a URL for this asset. + * @returns {string} a URL to download a project file. + */ +const getProjectUrl = function (asset) { + const assetIdParts = asset.assetId.split('.'); + const assetUrlParts = [PROJECT_SERVER, 'internalapi/project/', assetIdParts[0], '/get/']; + if (assetIdParts[1]) { + assetUrlParts.push(assetIdParts[1]); + } + return assetUrlParts.join(''); +}; + +/** + * @param {Asset} asset - calculate a URL for this asset. + * @returns {string} a URL to download a project asset (PNG, WAV, etc.) + */ +const getAssetUrl = function (asset) { + const assetUrlParts = [ + ASSET_SERVER, + 'internalapi/asset/', + asset.assetId, + '.', + asset.dataFormat, + '/get/' + ]; + return assetUrlParts.join(''); +}; + +class LoadingProgress { + constructor (callback) { + this.total = 0; + this.complete = 0; + this.callback = callback; + } + + on (storage) { + const _this = this; + const _load = storage.webHelper.load; + storage.webHelper.load = function (...args) { + const result = _load.call(this, ...args); + _this.total += 1; + _this.callback(_this); + result.then(() => { + _this.complete += 1; + _this.callback(_this); + }); + return result; + }; + } +} + +class StatTable { + constructor ({table, keys, viewOf, isSlow}) { + this.table = table; + if (keys) { + this.keys = keys; + } + if (viewOf) { + this.viewOf = viewOf; + } + if (isSlow) { + this.isSlow = isSlow; + } + } + + render () { + const table = this.table; + Array.from(table.children) + .forEach(node => table.removeChild(node)); + const keys = this.keys(); + for (const key of keys) { + this.viewOf(key).render({ + table, + isSlow: frame => this.isSlow(key, frame) + }); + } + } +} + +class StatView { + constructor (name) { + this.name = name; + this.executions = 0; + this.selfTime = 0; + this.totalTime = 0; + } + + update (selfTime, totalTime) { + this.executions++; + this.selfTime += selfTime; + this.totalTime += totalTime; + } + + render ({table, isSlow}) { + const row = document.createElement('tr'); + let cell = document.createElement('td'); + cell.innerText = this.name; + row.appendChild(cell); + + if (isSlow(this)) { + row.setAttribute('class', 'slow'); + } + + cell = document.createElement('td'); + // Truncate selfTime. Value past the microsecond are floating point + // noise. + this.selfTime = Math.floor(this.selfTime * 1000) / 1000; + cell.innerText = (this.selfTime / 1000).toPrecision(3); + row.appendChild(cell); + + cell = document.createElement('td'); + // Truncate totalTime. Value past the microsecond are floating point + // noise. + this.totalTime = Math.floor(this.totalTime * 1000) / 1000; + cell.innerText = (this.totalTime / 1000).toPrecision(3); + row.appendChild(cell); + + cell = document.createElement('td'); + cell.innerText = this.executions; + row.appendChild(cell); + + table.appendChild(row); + } +} + +class RunningStats { + constructor (profiler) { + this.stepThreadsInnerId = profiler.idByName('Sequencer.stepThreads#inner'); + this.blockFunctionId = profiler.idByName('blockFunction'); + this.stpeThreadsId = profiler.idByName('Sequencer.stepThreads'); + + this.recordedTime = 0; + this.executed = { + steps: 0, + blocks: 0 + }; + } + + update (id, selfTime, totalTime) { + if (id === this.stpeThreadsId) { + this.recordedTime += totalTime; + } else if (id === this.stepThreadsInnerId) { + this.executed.steps++; + } else if (id === this.blockFunctionId) { + this.executed.blocks++; + } + } +} + +const WORK_TIME = 0.75; + +class RunningStatsView { + constructor ({runningStats, maxRecordedTime, dom}) { + this.recordedTimeDom = + dom.getElementsByClassName('profile-count-amount-recorded')[0]; + this.stepsLoopedDom = + dom.getElementsByClassName('profile-count-steps-looped')[0]; + this.blocksExecutedDom = + dom.getElementsByClassName('profile-count-blocks-executed')[0]; + + this.maxRecordedTime = maxRecordedTime; + this.maxWorkedTime = maxRecordedTime * WORK_TIME; + this.runningStats = runningStats; + } + + render () { + const { + runningStats, + recordedTimeDom, + stepsLoopedDom, + blocksExecutedDom + } = this; + const {executed} = runningStats; + const fractionWorked = runningStats.recordedTime / this.maxWorkedTime; + recordedTimeDom.innerText = `${(fractionWorked * 100).toFixed(1)} %`; + stepsLoopedDom.innerText = executed.steps; + blocksExecutedDom.innerText = executed.blocks; + } +} + +class Frames { + constructor (profiler) { + this.profiler = profiler; + + this.frames = []; + } + + update (id, selfTime, totalTime) { + if (!this.frames[id]) { + this.frames[id] = new StatView(this.profiler.nameById(id)); + } + this.frames[id].update(selfTime, totalTime); + } +} + +const frameOrder = [ + 'blockFunction', + 'execute', + 'Sequencer.stepThread', + 'Sequencer.stepThreads#inner', + 'Sequencer.stepThreads', + 'RenderWebGL.draw', + 'Runtime._step' +]; + +const trackSlowFrames = [ + 'Sequencer.stepThreads', + 'Sequencer.stepThreads#inner', + 'Sequencer.stepThread', + 'execute' +]; + +class FramesTable extends StatTable { + constructor (options) { + super(options); + + this.profiler = options.profiler; + this.frames = options.frames; + } + + keys () { + const keys = Object.keys(this.frames.frames) + .map(id => this.profiler.nameById(Number(id))); + keys.sort((a, b) => frameOrder.indexOf(a) - frameOrder.indexOf(b)); + return keys; + } + + viewOf (key) { + return this.frames.frames[this.profiler.idByName(key)]; + } + + isSlow (key, frame) { + return (trackSlowFrames.indexOf(key) > 0 && + frame.selfTime / frame.totalTime > SLOW); + } +} + +class Opcodes { + constructor (profiler) { + this.blockFunctionId = profiler.idByName('blockFunction'); + + this.opcodes = {}; + } + + update (id, selfTime, totalTime, arg) { + if (id === this.blockFunctionId) { + if (!this.opcodes[arg]) { + this.opcodes[arg] = new StatView(arg); + } + this.opcodes[arg].update(selfTime, totalTime); + } + } +} + +class OpcodeTable extends StatTable { + constructor (options) { + super(options); + + this.profiler = options.profiler; + this.opcodes = options.opcodes; + this.frames = options.frames; + } + + keys () { + const keys = Object.keys(this.opcodes.opcodes); + keys.sort(); + return keys; + } + + viewOf (key) { + return this.opcodes.opcodes[key]; + } + + isSlow (key) { + const blockFunctionTotalTime = this.frames.frames[this.profiler.idByName('blockFunction')].totalTime; + const rowTotalTime = this.opcodes.opcodes[key].totalTime; + const percentOfRun = rowTotalTime / blockFunctionTotalTime; + return percentOfRun > SLOW; + } +} + +class ProfilerRun { + constructor ({vm, maxRecordedTime, warmUpTime}) { + this.vm = vm; + this.maxRecordedTime = maxRecordedTime; + this.warmUpTime = warmUpTime; + + vm.runtime.enableProfiling(); + const profiler = this.profiler = vm.runtime.profiler; + vm.runtime.profiler = null; + + const runningStats = this.runningStats = new RunningStats(profiler); + const runningStatsView = this.runningStatsView = new RunningStatsView({ + dom: document.getElementsByClassName('profile-count-group')[0], + + runningStats, + maxRecordedTime + }); + + const frames = this.frames = new Frames(profiler); + this.frameTable = new FramesTable({ + table: document + .getElementsByClassName('profile-count-frame-table')[0] + .getElementsByTagName('tbody')[0], + + profiler, + frames + }); + + const opcodes = this.opcodes = new Opcodes(profiler); + this.opcodeTable = new OpcodeTable({ + table: document + .getElementsByClassName('profile-count-opcode-table')[0] + .getElementsByTagName('tbody')[0], + + profiler, + opcodes, + frames + }); + + const stepId = profiler.idByName('Runtime._step'); + profiler.onFrame = ({id, selfTime, totalTime, arg}) => { + if (id === stepId) { + runningStatsView.render(); + } + runningStats.update(id, selfTime, totalTime, arg); + opcodes.update(id, selfTime, totalTime, arg); + frames.update(id, selfTime, totalTime, arg); + }; + } + + run () { + this.projectId = loadProject(); + + window.parent.postMessage({ + type: 'BENCH_MESSAGE_LOADING' + }, '*'); + + this.vm.on('workspaceUpdate', () => { + setTimeout(() => { + window.parent.postMessage({ + type: 'BENCH_MESSAGE_WARMING_UP' + }, '*'); + this.vm.greenFlag(); + }, 100); + setTimeout(() => { + window.parent.postMessage({ + type: 'BENCH_MESSAGE_ACTIVE' + }, '*'); + this.vm.runtime.profiler = this.profiler; + }, 100 + this.warmUpTime); + setTimeout(() => { + this.vm.stopAll(); + clearTimeout(this.vm.runtime._steppingInterval); + this.vm.runtime.profiler = null; + + this.frameTable.render(); + this.opcodeTable.render(); + + window.parent.postMessage({ + type: 'BENCH_MESSAGE_COMPLETE', + frames: this.frames.frames, + opcodes: this.opcodes.opcodes + }, '*'); + + setShareLink({ + fixture: { + projectId: this.projectId, + warmUpTime: this.warmUpTime, + recordingTime: this.maxRecordedTime + }, + frames: this.frames.frames, + opcodes: this.opcodes.opcodes + }); + }, 100 + this.warmUpTime + this.maxRecordedTime); + }); + } + + render (json) { + const {fixture} = json; + document.querySelector('[type=text]').value = [ + fixture.projectId, + fixture.warmUpTime, + fixture.recordingTime + ].join(','); + + this.frames.frames = json.frames.map( + frame => Object.assign(new StatView(), frame, { + name: this.profiler.nameById(this.profiler.idByName(frame.name)) + }) + ); + + this.opcodes.opcodes = {}; + Object.entries(json.opcodes).forEach(([opcode, data]) => { + this.opcodes.opcodes[opcode] = Object.assign(new StatView(), data); + }); + + this.frameTable.render(); + this.opcodeTable.render(); + } +} + +/** + * Run the benchmark with given parameters in the location's hash field or + * using defaults. + */ +const runBenchmark = function () { + // Lots of global variables to make debugging easier + // Instantiate the VM. + const vm = new window.VirtualMachine(); + Scratch.vm = vm; + + vm.setTurboMode(true); + + const storage = new ScratchStorage(); /* global ScratchStorage */ + const AssetType = storage.AssetType; + storage.addWebSource([AssetType.Project], getProjectUrl); + storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl); + vm.attachStorage(storage); + + new LoadingProgress(progress => { + document.getElementsByClassName('loading-total')[0] + .innerText = progress.total; + document.getElementsByClassName('loading-complete')[0] + .innerText = progress.complete; + }).on(storage); + + let warmUpTime = 4000; + let maxRecordedTime = 6000; + + if (location.hash) { + const split = location.hash.substring(1).split(','); + if (split[1] && split[1].length > 0) { + warmUpTime = Number(split[1]); + } + maxRecordedTime = Number(split[2] || '0') || 6000; + } + + new ProfilerRun({ + vm, + warmUpTime, + maxRecordedTime + }).run(); + + // Instantiate the renderer and connect it to the VM. + const canvas = document.getElementById('scratch-stage'); + const renderer = new window.RenderWebGL(canvas); + Scratch.renderer = renderer; + vm.attachRenderer(renderer); + const audioEngine = new window.AudioEngine(); + vm.attachAudioEngine(audioEngine); + + // Feed mouse events as VM I/O events. + document.addEventListener('mousemove', e => { + const rect = canvas.getBoundingClientRect(); + const coordinates = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + canvasWidth: rect.width, + canvasHeight: rect.height + }; + Scratch.vm.postIOData('mouse', coordinates); + }); + canvas.addEventListener('mousedown', e => { + const rect = canvas.getBoundingClientRect(); + const data = { + isDown: true, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + canvasWidth: rect.width, + canvasHeight: rect.height + }; + Scratch.vm.postIOData('mouse', data); + e.preventDefault(); + }); + canvas.addEventListener('mouseup', e => { + const rect = canvas.getBoundingClientRect(); + const data = { + isDown: false, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + canvasWidth: rect.width, + canvasHeight: rect.height + }; + Scratch.vm.postIOData('mouse', data); + e.preventDefault(); + }); + + // Feed keyboard events as VM I/O events. + document.addEventListener('keydown', e => { + // Don't capture keys intended for Blockly inputs. + if (e.target !== document && e.target !== document.body) { + return; + } + Scratch.vm.postIOData('keyboard', { + keyCode: e.keyCode, + isDown: true + }); + e.preventDefault(); + }); + document.addEventListener('keyup', e => { + // Always capture up events, + // even those that have switched to other targets. + Scratch.vm.postIOData('keyboard', { + keyCode: e.keyCode, + isDown: false + }); + // E.g., prevent scroll. + if (e.target !== document && e.target !== document.body) { + e.preventDefault(); + } + }); + + // Run threads + vm.start(); +}; + +/** + * Render previously run benchmark data. + * @param {object} json data from a previous benchmark run. + */ +const renderBenchmarkData = function (json) { + const vm = new window.VirtualMachine(); + new ProfilerRun({vm}).render(json); + setShareLink(json); +}; + +window.onload = function () { + if (location.hash.substring(1).startsWith('view')) { + document.body.className = 'render'; + const data = location.hash.substring(6); + const frozen = atob(data); + const json = JSON.parse(frozen); + renderBenchmarkData(json); + } else { + runBenchmark(); + } +}; + +window.onhashchange = function () { + location.reload(); +}; diff --git a/src/playground/index.html b/src/playground/index.html new file mode 100644 index 00000000..9a0fcfbe --- /dev/null +++ b/src/playground/index.html @@ -0,0 +1,91 @@ + + + + + + Scratch VM Benchmark + + + +

Scratch VM Benchmark

+

+ Welcome to the scratch-vm benchmark. This tool helps you profile a scratch + project. When you load the page, it: +

    +
  1. loads the default project and enables turbo mode +
  2. runs the project for 4 seconds to warm up +
  3. profiles for 6 seconds +
  4. stops and reports +
+

+
+ + +
+

+ Try a different project, like `130041250` +

+ +
+ +
+ + 0 / 0 +
+
+
+ + ... +
+
+ + ... +
+
+ + ... +
+ + +
+ +
+ + + + + + + +
FrameSelf TimeTotal TimeExecutions
+ + + + + + + + + + +
opcodeSelf TimeTotal TimeExecutions
+
+ +
+ + + + + + + + + + + diff --git a/src/playground/suite.css b/src/playground/suite.css new file mode 100644 index 00000000..06df9a3f --- /dev/null +++ b/src/playground/suite.css @@ -0,0 +1,78 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +iframe { + border: none; +} + +.runner-controls { + position: absolute; + width: 30em; + height: 100%; + left: 0; + right: 30em; + top: 0; + bottom: 0; + overflow: scroll; + padding: 1em; + padding-top: 0; + box-sizing: border-box; +} + +.bench-frame-owner { + position: absolute; + width: calc(100% - 30em); + height: 100%; + left: 30em; + right: 100%; + top: 0; + bottom: 0; +} + +.controls { + margin-bottom: 1em; +} + +.legend { + margin: 1em 0; +} + +.result-view { + border-bottom: 1px solid #ccc; + border-spacing: 0; + border-collapse: collapse; + padding: 5px; +} + +.fixture-project { + display: inline-block; + clear: both; +} + +.fixture-warm-up { + display: inline-block; +} + +.fixture-recording { + display: inline-block; +} + +.result-view.resume { + cursor: pointer; +} + +.result-status { + float: right; + text-align: right; + margin-left: 0.3em; +} + +.compare-file { + cursor: pointer; + visibility: hidden; + width: 0; +} diff --git a/src/playground/suite.html b/src/playground/suite.html new file mode 100644 index 00000000..4100ddd8 --- /dev/null +++ b/src/playground/suite.html @@ -0,0 +1,25 @@ + + + + + + Scratch VM Benchmark Suite + + + +
+

Scratch VM Benchmark Suite

+
+ +
+
+ +
+ +
+ + + + + diff --git a/src/playground/suite.js b/src/playground/suite.js new file mode 100644 index 00000000..8ab0d492 --- /dev/null +++ b/src/playground/suite.js @@ -0,0 +1,600 @@ +const soon = (() => { + let _soon; + return () => { + if (!_soon) { + _soon = Promise.resolve() + .then(() => { + _soon = null; + }); + } + return _soon; + }; +})(); + +class Emitter { + constructor () { + Object.defineProperty(this, '_listeners', { + value: {}, + enumerable: false + }); + } + on (name, listener, context) { + if (!this._listeners[name]) { + this._listeners[name] = []; + } + + this._listeners[name].push(listener, context); + } + off (name, listener, context) { + if (this._listeners[name]) { + if (listener) { + for (let i = 0; i < this._listeners[name].length; i += 2) { + if ( + this._listeners[name][i] === listener && + this._listeners[name][i + 1] === context) { + this._listeners[name].splice(i, 2); + i -= 2; + } + } + } else { + for (let i = 0; i < this._listeners[name].length; i += 2) { + if (this._listeners[name][i + 1] === context) { + this._listeners[name].splice(i, 2); + i -= 2; + } + } + } + } + } + emit (name, ...args) { + if (this._listeners[name]) { + for (let i = 0; i < this._listeners[name].length; i += 2) { + this._listeners[name][i].call(this._listeners[name][i + 1] || this, ...args); + } + } + } +} + +class BenchFrameStream extends Emitter { + constructor (frame) { + super(); + + this.frame = frame; + window.addEventListener('message', message => { + this.emit('message', message.data); + }); + } + + send (message) { + this.frame.send(message); + } +} + +const benchmarkUrlArgs = args => ( + [ + args.projectId, + args.warmUpTime, + args.recordingTime + ].join(',') +); + +const BENCH_MESSAGE_TYPE = { + INACTIVE: 'BENCH_MESSAGE_INACTIVE', + LOAD: 'BENCH_MESSAGE_LOAD', + LOADING: 'BENCH_MESSAGE_LOADING', + WARMING_UP: 'BENCH_MESSAGE_WARMING_UP', + ACTIVE: 'BENCH_MESSAGE_ACTIVE', + COMPLETE: 'BENCH_MESSAGE_COMPLETE' +}; + +class BenchUtil { + constructor (frame) { + this.frame = frame; + this.benchStream = new BenchFrameStream(frame); + } + + setFrameLocation (url) { + this.frame.contentWindow.location.assign(url); + } + + startBench (args) { + this.benchArgs = args; + this.setFrameLocation(`index.html#${benchmarkUrlArgs(args)}`); + } + + pauseBench () { + new Promise(resolve => setTimeout(resolve, 1000)) + .then(() => { + this.benchStream.emit('message', { + type: BENCH_MESSAGE_TYPE.INACTIVE + }); + }); + } + + resumeBench () { + this.startBench(this.benchArgs); + } + + renderResults (results) { + this.setFrameLocation( + `index.html#view/${btoa(JSON.stringify(results))}` + ); + } +} + +const BENCH_STATUS = { + INACTIVE: 'BENCH_STATUS_INACTIVE', + RESUME: 'BENCH_STATUS_RESUME', + STARTING: 'BENCH_STATUS_STARTING', + LOADING: 'BENCH_STATUS_LOADING', + WARMING_UP: 'BENCH_STATUS_WARMING_UP', + ACTIVE: 'BENCH_STATUS_ACTIVE', + COMPLETE: 'BENCH_STATUS_COMPLETE' +}; + +class BenchResult { + constructor ({fixture, status = BENCH_STATUS.INACTIVE, frames = null, opcodes = null}) { + this.fixture = fixture; + this.status = status; + this.frames = frames; + this.opcodes = opcodes; + } +} + +class BenchFixture extends Emitter { + constructor ({ + projectId, + warmUpTime = 4000, + recordingTime = 6000 + }) { + super(); + + this.projectId = projectId; + this.warmUpTime = warmUpTime; + this.recordingTime = recordingTime; + } + + get id () { + return `${this.projectId}-${this.warmUpTime}-${this.recordingTime}`; + } + + run (util) { + return new Promise(resolve => { + util.benchStream.on('message', message => { + const result = { + fixture: this, + status: BENCH_STATUS.STARTING, + frames: null, + opcodes: null + }; + if (message.type === BENCH_MESSAGE_TYPE.INACTIVE) { + result.status = BENCH_STATUS.RESUME; + } else if (message.type === BENCH_MESSAGE_TYPE.LOADING) { + result.status = BENCH_STATUS.LOADING; + } else if (message.type === BENCH_MESSAGE_TYPE.WARMING_UP) { + result.status = BENCH_STATUS.WARMING_UP; + } else if (message.type === BENCH_MESSAGE_TYPE.ACTIVE) { + result.status = BENCH_STATUS.ACTIVE; + } else if (message.type === BENCH_MESSAGE_TYPE.COMPLETE) { + result.status = BENCH_STATUS.COMPLETE; + result.frames = message.frames; + result.opcodes = message.opcodes; + resolve(new BenchResult(result)); + util.benchStream.off('message', null, this); + } + this.emit('result', new BenchResult(result)); + }, this); + util.startBench(this); + }); + } +} + +class BenchSuiteResult extends Emitter { + constructor ({suite, results = []}) { + super(); + + this.suite = suite; + this.results = results; + + if (suite) { + suite.on('result', result => { + if (result.status === BENCH_STATUS.COMPLETE) { + this.results.push(results); + this.emit('add', this); + } + }); + } + } +} + +class BenchSuite extends Emitter { + constructor (fixtures = []) { + super(); + + this.fixtures = fixtures; + } + + add (fixture) { + this.fixtures.push(fixture); + } + + run (util) { + return new Promise(resolve => { + const fixtures = this.fixtures.slice(); + const results = []; + const push = result => { + result.fixture.off('result', null, this); + results.push(result); + }; + const emitResult = this.emit.bind(this, 'result'); + const pop = () => { + const fixture = fixtures.shift(); + if (fixture) { + fixture.on('result', emitResult, this); + fixture.run(util) + .then(push) + .then(pop); + } else { + resolve(new BenchSuiteResult({suite: this, results})); + } + }; + pop(); + }); + } +} + +class BenchRunner extends Emitter { + constructor ({frame, suite}) { + super(); + + this.frame = frame; + this.suite = suite; + this.util = new BenchUtil(frame); + } + + run () { + return this.suite.run(this.util); + } +} + +const viewNames = { + [BENCH_STATUS.INACTIVE]: 'Inactive', + [BENCH_STATUS.RESUME]: 'Resume', + [BENCH_STATUS.STARTING]: 'Starting', + [BENCH_STATUS.LOADING]: 'Loading', + [BENCH_STATUS.WARMING_UP]: 'Warming Up', + [BENCH_STATUS.ACTIVE]: 'Active', + [BENCH_STATUS.COMPLETE]: 'Complete' +}; + +class BenchResultView { + constructor ({result, benchUtil}) { + this.result = result; + this.compare = null; + this.benchUtil = benchUtil; + this.dom = document.createElement('div'); + } + + update (result) { + soon().then(() => this.render(result)); + } + + resume () { + this.benchUtil.resumeBench(); + } + + setFrameLocation (loc) { + this.benchUtil.pauseBench(); + this.benchUtil.setFrameLocation(loc); + } + + act (ev) { + if ( + ev.type === 'click' && + ev.button === 0 && + !(ev.altKey || ev.ctrlKey || ev.shiftKey || ev.metaKey) + ) { + let target = ev.target; + while (target && target.tagName.toLowerCase() !== 'a') { + target = target.parentElement; + } + if (target && target.tagName.toLowerCase() === 'a') { + if (target.href) { + this.setFrameLocation(target.href); + ev.preventDefault(); + } + } else if (ev.currentTarget.classList.contains('resume')) { + this.resume(); + } + } + } + + render (newResult = this.result, compareResult = this.compare) { + const newResultFrames = (newResult.frames ? newResult.frames : []); + const blockFunctionFrame = newResultFrames + .find(frame => frame.name === 'blockFunction'); + const stepThreadsInnerFrame = newResultFrames + .find(frame => frame.name === 'Sequencer.stepThreads#inner'); + + const blocksPerSecond = blockFunctionFrame ? + (blockFunctionFrame.executions / + (stepThreadsInnerFrame.totalTime / 1000)) | 0 : + 0; + const stepsPerSecond = stepThreadsInnerFrame ? + (stepThreadsInnerFrame.executions / + (stepThreadsInnerFrame.totalTime / 1000)) | 0 : + 0; + + const compareResultFrames = ( + compareResult && compareResult.frames ? + compareResult.frames : + [] + ); + const blockFunctionCompareFrame = compareResultFrames + .find(frame => frame.name === 'blockFunction'); + const stepThreadsInnerCompareFrame = compareResultFrames + .find(frame => frame.name === 'Sequencer.stepThreads#inner'); + + const compareBlocksPerSecond = blockFunctionCompareFrame ? + (blockFunctionCompareFrame.executions / + (stepThreadsInnerCompareFrame.totalTime / 1000)) | 0 : + 0; + const compareStepsPerSecond = stepThreadsInnerCompareFrame ? + (stepThreadsInnerCompareFrame.executions / + (stepThreadsInnerCompareFrame.totalTime / 1000)) | 0 : + 0; + + const statusName = viewNames[newResult.status]; + + this.dom.className = `result-view ${ + viewNames[newResult.status].toLowerCase() + }`; + this.dom.onclick = this.act.bind(this); + let url = `index.html#${benchmarkUrlArgs(newResult.fixture)}`; + if (newResult.status === BENCH_STATUS.COMPLETE) { + url = `index.html#view/${btoa(JSON.stringify(newResult))}`; + } + let compareUrl = url; + if (compareResult && compareResult) { + compareUrl = + `index.html#view/${btoa(JSON.stringify(compareResult))}`; + } + let compareHTML = ''; + if (stepThreadsInnerFrame && stepThreadsInnerCompareFrame) { + compareHTML = ` +
+
${compareStepsPerSecond}
+
${compareBlocksPerSecond}
+
+
`; + } + this.dom.innerHTML = ` + +
+
${stepThreadsInnerFrame ? `steps/s` : ''}
+
${blockFunctionFrame ? `blocks/s` : statusName}
+
+ +
+
${stepThreadsInnerFrame ? `${stepsPerSecond}` : ''}
+
${blockFunctionFrame ? `${blocksPerSecond}` : ''}
+
+
+ ${compareHTML} +
+ Run for ${newResult.fixture.recordingTime / 1000} seconds after + ${newResult.fixture.warmUpTime / 1000} seconds +
+ `; + + this.result = newResult; + return this; + } +} + +class BenchSuiteResultView { + constructor ({runner}) { + const {suite, util} = runner; + + this.runner = runner; + this.suite = suite; + this.views = {}; + this.dom = document.createElement('div'); + + for (const fixture of suite.fixtures) { + this.views[fixture.id] = new BenchResultView({ + result: new BenchResult({fixture}), + benchUtil: util + }); + } + + suite.on('result', result => { + this.views[result.fixture.id].update(result); + }); + } + + render () { + this.dom.innerHTML = `
+ Project ID +
+
steps per second
+
blocks per second
+
+
Description
+
+ + `; + + for (const fixture of this.suite.fixtures) { + this.dom.appendChild(this.views[fixture.id].render().dom); + } + + return this; + } +} + +let suite; +let suiteView; + +window.upload = function (_this) { + if (!_this.files.length) { + return; + } + const reader = new FileReader(); + reader.onload = function () { + const report = JSON.parse(reader.result); + Object.values(suiteView.views) + .forEach(view => { + const sameFixture = report.results.find(result => ( + result.fixture.projectId === + view.result.fixture.projectId && + result.fixture.warmUpTime === + view.result.fixture.warmUpTime && + result.fixture.recordingTime === + view.result.fixture.recordingTime + )); + + if (sameFixture) { + if ( + view.result && view.result.frames && + view.result.frames.length > 0 + ) { + view.render(view.result, sameFixture); + } else { + view.compare = sameFixture; + } + } + }); + }; + reader.readAsText(_this.files[0]); +}; + +window.download = function (_this) { + const blob = new Blob([JSON.stringify({ + meta: { + source: 'Scratch VM Benchmark Suite', + version: 1 + }, + results: Object.values(suiteView.views) + .map(view => view.result) + .filter(view => view.status === BENCH_STATUS.COMPLETE) + })], {type: 'application/json'}); + + _this.download = 'scratch-vm-benchmark.json'; + _this.href = URL.createObjectURL(blob); +}; + +window.onload = function () { + suite = new BenchSuite(); + + suite.add(new BenchFixture({ + projectId: 130041250, + warmUpTime: 0, + recordingTime: 2000 + })); + + suite.add(new BenchFixture({ + projectId: 130041250, + warmUpTime: 4000, + recordingTime: 6000 + })); + + suite.add(new BenchFixture({ + projectId: 14844969, + warmUpTime: 0, + recordingTime: 2000 + })); + + suite.add(new BenchFixture({ + projectId: 14844969, + warmUpTime: 1000, + recordingTime: 6000 + })); + + suite.add(new BenchFixture({ + projectId: 173918262, + warmUpTime: 0, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 173918262, + warmUpTime: 5000, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 155128646, + warmUpTime: 0, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 155128646, + warmUpTime: 5000, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 89811578, + warmUpTime: 0, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 89811578, + warmUpTime: 5000, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 139193539, + warmUpTime: 0, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 139193539, + warmUpTime: 5000, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 187694931, + warmUpTime: 0, + recordingTime: 5000 + })); + + suite.add(new BenchFixture({ + projectId: 187694931, + warmUpTime: 5000, + recordingTime: 5000 + })); + + const frame = document.getElementsByTagName('iframe')[0]; + const runner = new BenchRunner({frame, suite}); + const resultsView = suiteView = new BenchSuiteResultView({runner}).render(); + + document.getElementsByClassName('suite-results')[0] + .appendChild(resultsView.dom); + + runner.run(); +}; diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js new file mode 100644 index 00000000..1102c005 --- /dev/null +++ b/src/serialization/sb2.js @@ -0,0 +1,706 @@ +/** + * @fileoverview + * Partial implementation of an SB2 JSON importer. + * Parses provided JSON and then generates all needed + * scratch-vm runtime structures. + */ + +const Blocks = require('../engine/blocks'); +const RenderedTarget = require('../sprites/rendered-target'); +const Sprite = require('../sprites/sprite'); +const Color = require('../util/color'); +const log = require('../util/log'); +const uid = require('../util/uid'); +const specMap = require('./sb2_specmap'); +const Variable = require('../engine/variable'); + +const {loadCostume} = require('../import/load-costume.js'); +const {loadSound} = require('../import/load-sound.js'); + +/** + * Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n") + * into an argument map. This allows us to provide the expected inputs + * to a mutated procedure call. + * @param {string} procCode Scratch 2.0 procedure string. + * @return {object} Argument map compatible with those in sb2specmap. + */ +const parseProcedureArgMap = function (procCode) { + const argMap = [ + {} // First item in list is op string. + ]; + const INPUT_PREFIX = 'input'; + let inputCount = 0; + // Split by %n, %b, %s. + const parts = procCode.split(/(?=[^\\]%[nbs])/); + for (let i = 0; i < parts.length; i++) { + const part = parts[i].trim(); + if (part.substring(0, 1) === '%') { + const argType = part.substring(1, 2); + const arg = { + type: 'input', + inputName: INPUT_PREFIX + (inputCount++) + }; + if (argType === 'n') { + arg.inputOp = 'math_number'; + } else if (argType === 's') { + arg.inputOp = 'text'; + } + argMap.push(arg); + } + } + return argMap; +}; + +/** + * Generate a list of "argument IDs" for procdefs and caller mutations. + * IDs just end up being `input0`, `input1`, ... which is good enough. + * @param {string} procCode Scratch 2.0 procedure string. + * @return {Array.} Array of argument id strings. + */ +const parseProcedureArgIds = function (procCode) { + return parseProcedureArgMap(procCode) + .map(arg => arg.inputName) + .filter(name => name); // Filter out unnamed inputs which are labels +}; + +/** + * Flatten a block tree into a block list. + * Children are temporarily stored on the `block.children` property. + * @param {Array.} blocks list generated by `parseBlockList`. + * @return {Array.} Flattened list to be passed to `blocks.createBlock`. + */ +const flatten = function (blocks) { + let finalBlocks = []; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + finalBlocks.push(block); + if (block.children) { + finalBlocks = finalBlocks.concat(flatten(block.children)); + } + delete block.children; + } + return finalBlocks; +}; + +/** + * Parse any list of blocks from SB2 JSON into a list of VM-format blocks. + * Could be used to parse a top-level script, + * a list of blocks in a branch (e.g., in forever), + * or a list of blocks in an argument (e.g., move [pick random...]). + * @param {Array.} blockList SB2 JSON-format block list. + * @param {Function} addBroadcastMsg function to update broadcast message name map + * @param {Function} getVariableId function to retreive a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @return {Array.} Scratch VM-format block list. + */ +const parseBlockList = function (blockList, addBroadcastMsg, getVariableId, extensions) { + const resultingList = []; + let previousBlock = null; // For setting next. + for (let i = 0; i < blockList.length; i++) { + const block = blockList[i]; + // eslint-disable-next-line no-use-before-define + const parsedBlock = parseBlock(block, addBroadcastMsg, getVariableId, extensions); + if (typeof parsedBlock === 'undefined') continue; + if (previousBlock) { + parsedBlock.parent = previousBlock.id; + previousBlock.next = parsedBlock.id; + } + previousBlock = parsedBlock; + resultingList.push(parsedBlock); + } + return resultingList; +}; + +/** + * Parse a Scratch object's scripts into VM blocks. + * This should only handle top-level scripts that include X, Y coordinates. + * @param {!object} scripts Scripts object from SB2 JSON. + * @param {!Blocks} blocks Blocks object to load parsed blocks into. + * @param {Function} addBroadcastMsg function to update broadcast message name map + * @param {Function} getVariableId function to retreive a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + */ +const parseScripts = function (scripts, blocks, addBroadcastMsg, getVariableId, extensions) { + for (let i = 0; i < scripts.length; i++) { + const script = scripts[i]; + const scriptX = script[0]; + const scriptY = script[1]; + const blockList = script[2]; + const parsedBlockList = parseBlockList(blockList, addBroadcastMsg, getVariableId, extensions); + if (parsedBlockList[0]) { + // Adjust script coordinates to account for + // larger block size in scratch-blocks. + // @todo: Determine more precisely the right formulas here. + parsedBlockList[0].x = scriptX * 1.5; + parsedBlockList[0].y = scriptY * 2.2; + parsedBlockList[0].topLevel = true; + parsedBlockList[0].parent = null; + } + // Flatten children and create add the blocks. + const convertedBlocks = flatten(parsedBlockList); + for (let j = 0; j < convertedBlocks.length; j++) { + blocks.createBlock(convertedBlocks[j]); + } + } +}; + +/** + * Create a callback for assigning fixed IDs to imported variables + * Generator stores the global variable mapping in a closure + * @param {!string} targetId the id of the target to scope the variable to + * @return {string} variable ID + */ +const generateVariableIdGetter = (function () { + let globalVariableNameMap = {}; + const namer = (targetId, name) => `${targetId}-${name}`; + return function (targetId, topLevel) { + // Reset the global variable map if topLevel + if (topLevel) globalVariableNameMap = {}; + return function (name) { + if (topLevel) { // Store the name/id pair in the globalVariableNameMap + globalVariableNameMap[name] = namer(targetId, name); + return globalVariableNameMap[name]; + } + // Not top-level, so first check the global name map + if (globalVariableNameMap[name]) return globalVariableNameMap[name]; + return namer(targetId, name); + }; + }; +}()); + +const globalBroadcastMsgStateGenerator = (function () { + let broadcastMsgNameMap = {}; + const allBroadcastFields = []; + const emptyStringName = uid(); + return function (topLevel) { + if (topLevel) broadcastMsgNameMap = {}; + return { + broadcastMsgMapUpdater: function (name, field) { + name = name.toLowerCase(); + if (name === '') { + name = emptyStringName; + } + broadcastMsgNameMap[name] = `broadcastMsgId-${name}`; + allBroadcastFields.push(field); + return broadcastMsgNameMap[name]; + }, + globalBroadcastMsgs: broadcastMsgNameMap, + allBroadcastFields: allBroadcastFields, + emptyMsgName: emptyStringName + }; + }; +}()); + +/** + * Parse a single "Scratch object" and create all its in-memory VM objects. + * TODO: parse the "info" section, especially "savedExtensions" + * @param {!object} object - From-JSON "Scratch object:" sprite, stage, watcher. + * @param {!Runtime} runtime - Runtime object to load all structures into. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {boolean} topLevel - Whether this is the top-level object (stage). + * @return {!Promise.>} Promise for the loaded targets when ready, or null for unsupported objects. + */ +const parseScratchObject = function (object, runtime, extensions, topLevel) { + if (!object.hasOwnProperty('objName')) { + // Watcher/monitor - skip this object until those are implemented in VM. + // @todo + return Promise.resolve(null); + } + // Blocks container for this object. + const blocks = new Blocks(); + // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. + const sprite = new Sprite(blocks, runtime); + // Sprite/stage name from JSON. + if (object.hasOwnProperty('objName')) { + sprite.name = object.objName; + } + // Costumes from JSON. + const costumePromises = []; + if (object.hasOwnProperty('costumes')) { + for (let i = 0; i < object.costumes.length; i++) { + const costumeSource = object.costumes[i]; + const costume = { + name: costumeSource.costumeName, + bitmapResolution: costumeSource.bitmapResolution || 1, + rotationCenterX: costumeSource.rotationCenterX, + rotationCenterY: costumeSource.rotationCenterY, + skinId: null + }; + costumePromises.push(loadCostume(costumeSource.baseLayerMD5, costume, runtime)); + } + } + // Sounds from JSON + const soundPromises = []; + if (object.hasOwnProperty('sounds')) { + for (let s = 0; s < object.sounds.length; s++) { + const soundSource = object.sounds[s]; + const sound = { + name: soundSource.soundName, + format: soundSource.format, + rate: soundSource.rate, + sampleCount: soundSource.sampleCount, + soundID: soundSource.soundID, + md5: soundSource.md5, + data: null + }; + soundPromises.push(loadSound(sound, runtime)); + } + } + + // Create the first clone, and load its run-state from JSON. + const target = sprite.createClone(); + + const getVariableId = generateVariableIdGetter(target.id, topLevel); + + const globalBroadcastMsgObj = globalBroadcastMsgStateGenerator(topLevel); + const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater; + + // Load target properties from JSON. + if (object.hasOwnProperty('variables')) { + for (let j = 0; j < object.variables.length; j++) { + const variable = object.variables[j]; + const newVariable = new Variable( + getVariableId(variable.name), + variable.name, + Variable.SCALAR_TYPE, + variable.isPersistent + ); + newVariable.value = variable.value; + target.variables[newVariable.id] = newVariable; + } + } + + // If included, parse any and all scripts/blocks on the object. + if (object.hasOwnProperty('scripts')) { + parseScripts(object.scripts, blocks, addBroadcastMsg, getVariableId, extensions); + } + + if (object.hasOwnProperty('lists')) { + for (let k = 0; k < object.lists.length; k++) { + const list = object.lists[k]; + // @todo: monitor properties. + const newVariable = new Variable( + getVariableId(list.listName), + list.listName, + Variable.LIST_TYPE, + false + ); + newVariable.value = list.contents; + target.variables[newVariable.id] = newVariable; + } + } + if (object.hasOwnProperty('scratchX')) { + target.x = object.scratchX; + } + if (object.hasOwnProperty('scratchY')) { + target.y = object.scratchY; + } + if (object.hasOwnProperty('direction')) { + target.direction = object.direction; + } + if (object.hasOwnProperty('isDraggable')) { + target.draggable = object.isDraggable; + } + if (object.hasOwnProperty('scale')) { + // SB2 stores as 1.0 = 100%; we use % in the VM. + target.size = object.scale * 100; + } + if (object.hasOwnProperty('visible')) { + target.visible = object.visible; + } + if (object.hasOwnProperty('currentCostumeIndex')) { + target.currentCostume = Math.round(object.currentCostumeIndex); + } + if (object.hasOwnProperty('rotationStyle')) { + if (object.rotationStyle === 'none') { + target.rotationStyle = RenderedTarget.ROTATION_STYLE_NONE; + } else if (object.rotationStyle === 'leftRight') { + target.rotationStyle = RenderedTarget.ROTATION_STYLE_LEFT_RIGHT; + } else if (object.rotationStyle === 'normal') { + target.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND; + } + } + + target.isStage = topLevel; + + Promise.all(costumePromises).then(costumes => { + sprite.costumes = costumes; + }); + + Promise.all(soundPromises).then(sounds => { + sprite.sounds = sounds; + }); + + // The stage will have child objects; recursively process them. + const childrenPromises = []; + if (object.children) { + for (let m = 0; m < object.children.length; m++) { + childrenPromises.push(parseScratchObject(object.children[m], runtime, extensions, false)); + } + } + + return Promise.all( + costumePromises.concat(soundPromises) + ).then(() => + Promise.all( + childrenPromises + ).then(children => { + // Need create broadcast msgs as variables after + // all other targets have finished processing. + if (target.isStage) { + const allBroadcastMsgs = globalBroadcastMsgObj.globalBroadcastMsgs; + const allBroadcastMsgFields = globalBroadcastMsgObj.allBroadcastFields; + const oldEmptyMsgName = globalBroadcastMsgObj.emptyMsgName; + if (allBroadcastMsgs[oldEmptyMsgName]) { + // Find a fresh 'messageN' + let currIndex = 1; + while (allBroadcastMsgs[`message${currIndex}`]) { + currIndex += 1; + } + const newEmptyMsgName = `message${currIndex}`; + // Add the new empty message name to the broadcast message + // name map, and assign it the old id. + // Then, delete the old entry in map. + allBroadcastMsgs[newEmptyMsgName] = allBroadcastMsgs[oldEmptyMsgName]; + delete allBroadcastMsgs[oldEmptyMsgName]; + // Now update all the broadcast message fields with + // the new empty message name. + for (let i = 0; i < allBroadcastMsgFields.length; i++) { + if (allBroadcastMsgFields[i].value === '') { + allBroadcastMsgFields[i].value = newEmptyMsgName; + } + } + } + // Traverse the broadcast message name map and create + // broadcast messages as variables on the stage (which is this + // target). + for (const msgName in allBroadcastMsgs) { + const msgId = allBroadcastMsgs[msgName]; + const newMsg = new Variable( + msgId, + msgName, + Variable.BROADCAST_MESSAGE_TYPE, + false + ); + target.variables[newMsg.id] = newMsg; + } + } + let targets = [target]; + for (let n = 0; n < children.length; n++) { + targets = targets.concat(children[n]); + } + return targets; + }) + ); +}; + +/** + * Top-level handler. Parse provided JSON, + * and process the top-level object (the stage object). + * @param {!object} json SB2-format JSON to load. + * @param {!Runtime} runtime Runtime object to load all structures into. + * @param {boolean=} optForceSprite If set, treat as sprite (Sprite2). + * @return {Promise.} Promise that resolves to the loaded targets when ready. + */ +const sb2import = function (json, runtime, optForceSprite) { + const extensions = { + extensionIDs: new Set(), + extensionURLs: new Map() + }; + return parseScratchObject(json, runtime, extensions, !optForceSprite) + .then(targets => ({ + targets, + extensions + })); +}; + +/** + * Parse a single SB2 JSON-formatted block and its children. + * @param {!object} sb2block SB2 JSON-formatted block. + * @param {Function} addBroadcastMsg function to update broadcast message name map + * @param {Function} getVariableId function to retrieve a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @return {object} Scratch VM format block, or null if unsupported object. + */ +const parseBlock = function (sb2block, addBroadcastMsg, getVariableId, extensions) { + // First item in block object is the old opcode (e.g., 'forward:'). + const oldOpcode = sb2block[0]; + // Convert the block using the specMap. See sb2specmap.js. + if (!oldOpcode || !specMap[oldOpcode]) { + log.warn('Couldn\'t find SB2 block: ', oldOpcode); + return null; + } + const blockMetadata = specMap[oldOpcode]; + // If the block is from an extension, record it. + const dotIndex = blockMetadata.opcode.indexOf('.'); + if (dotIndex >= 0) { + const extension = blockMetadata.opcode.substring(0, dotIndex); + extensions.extensionIDs.add(extension); + } + // Block skeleton. + const activeBlock = { + id: uid(), // Generate a new block unique ID. + opcode: blockMetadata.opcode, // Converted, e.g. "motion_movesteps". + inputs: {}, // Inputs to this block and the blocks they point to. + fields: {}, // Fields on this block and their values. + next: null, // Next block. + shadow: false, // No shadow blocks in an SB2 by default. + children: [] // Store any generated children, flattened in `flatten`. + }; + // For a procedure call, generate argument map from proc string. + if (oldOpcode === 'call') { + blockMetadata.argMap = parseProcedureArgMap(sb2block[1]); + } + // Look at the expected arguments in `blockMetadata.argMap.` + // The basic problem here is to turn positional SB2 arguments into + // non-positional named Scratch VM arguments. + for (let i = 0; i < blockMetadata.argMap.length; i++) { + const expectedArg = blockMetadata.argMap[i]; + const providedArg = sb2block[i + 1]; // (i = 0 is opcode) + // Whether the input is obscuring a shadow. + let shadowObscured = false; + // Positional argument is an input. + if (expectedArg.type === 'input') { + // Create a new block and input metadata. + const inputUid = uid(); + activeBlock.inputs[expectedArg.inputName] = { + name: expectedArg.inputName, + block: null, + shadow: null + }; + if (typeof providedArg === 'object' && providedArg) { + // Block or block list occupies the input. + let innerBlocks; + if (typeof providedArg[0] === 'object' && providedArg[0]) { + // Block list occupies the input. + innerBlocks = parseBlockList(providedArg, addBroadcastMsg, getVariableId, extensions); + } else { + // Single block occupies the input. + innerBlocks = [parseBlock(providedArg, addBroadcastMsg, getVariableId, extensions)]; + } + let previousBlock = null; + for (let j = 0; j < innerBlocks.length; j++) { + if (j === 0) { + innerBlocks[j].parent = activeBlock.id; + } else { + innerBlocks[j].parent = previousBlock; + } + previousBlock = innerBlocks[j].id; + } + // Obscures any shadow. + shadowObscured = true; + activeBlock.inputs[expectedArg.inputName].block = ( + innerBlocks[0].id + ); + activeBlock.children = ( + activeBlock.children.concat(innerBlocks) + ); + } + // Generate a shadow block to occupy the input. + if (!expectedArg.inputOp) { + // No editable shadow input; e.g., for a boolean. + continue; + } + // Each shadow has a field generated for it automatically. + // Value to be filled in the field. + let fieldValue = providedArg; + // Shadows' field names match the input name, except for these: + let fieldName = expectedArg.inputName; + if (expectedArg.inputOp === 'math_number' || + expectedArg.inputOp === 'math_whole_number' || + expectedArg.inputOp === 'math_positive_number' || + expectedArg.inputOp === 'math_integer' || + expectedArg.inputOp === 'math_angle') { + fieldName = 'NUM'; + // Fields are given Scratch 2.0 default values if obscured. + if (shadowObscured) { + fieldValue = 10; + } + } else if (expectedArg.inputOp === 'text') { + fieldName = 'TEXT'; + if (shadowObscured) { + fieldValue = ''; + } + } else if (expectedArg.inputOp === 'colour_picker') { + // Convert SB2 color to hex. + fieldValue = Color.decimalToHex(providedArg); + fieldName = 'COLOUR'; + if (shadowObscured) { + fieldValue = '#990000'; + } + } else if (expectedArg.inputOp === 'event_broadcast_menu') { + fieldName = 'BROADCAST_OPTION'; + if (shadowObscured) { + fieldValue = ''; + } + } else if (shadowObscured) { + // Filled drop-down menu. + fieldValue = ''; + } + const fields = {}; + fields[fieldName] = { + name: fieldName, + value: fieldValue + }; + // event_broadcast_menus have some extra properties to add to the + // field and a different value than the rest + if (expectedArg.inputOp === 'event_broadcast_menu') { + if (!shadowObscured) { + // Need to update the broadcast message name map with + // the value of this field. + // Also need to provide the fields[fieldName] object, + // so that we can later update its value property, e.g. + // if sb2 message name is empty string, we will later + // replace this field's value with messageN + // once we can traverse through all the existing message names + // and come up with a fresh messageN. + const broadcastId = addBroadcastMsg(fieldValue, fields[fieldName]); + fields[fieldName].id = broadcastId; + } + fields[fieldName].variableType = expectedArg.variableType; + } + activeBlock.children.push({ + id: inputUid, + opcode: expectedArg.inputOp, + inputs: {}, + fields: fields, + next: null, + topLevel: false, + parent: activeBlock.id, + shadow: true + }); + activeBlock.inputs[expectedArg.inputName].shadow = inputUid; + // If no block occupying the input, alias to the shadow. + if (!activeBlock.inputs[expectedArg.inputName].block) { + activeBlock.inputs[expectedArg.inputName].block = inputUid; + } + } else if (expectedArg.type === 'field') { + // Add as a field on this block. + activeBlock.fields[expectedArg.fieldName] = { + name: expectedArg.fieldName, + value: providedArg + }; + + if (expectedArg.fieldName === 'VARIABLE' || expectedArg.fieldName === 'LIST') { + // Add `id` property to variable fields + activeBlock.fields[expectedArg.fieldName].id = getVariableId(providedArg); + } else if (expectedArg.fieldName === 'BROADCAST_OPTION') { + // Add the name in this field to the broadcast msg name map. + // Also need to provide the fields[fieldName] object, + // so that we can later update its value property, e.g. + // if sb2 message name is empty string, we will later + // replace this field's value with messageN + // once we can traverse through all the existing message names + // and come up with a fresh messageN. + const broadcastId = addBroadcastMsg(providedArg, activeBlock.fields[expectedArg.fieldName]); + activeBlock.fields[expectedArg.fieldName].id = broadcastId; + } + const varType = expectedArg.variableType; + if (typeof varType === 'string') { + activeBlock.fields[expectedArg.fieldName].variableType = varType; + } + } + } + + // Updates for blocks that have new menus (e.g. in Looks) + switch (oldOpcode) { + case 'comeToFront': + activeBlock.fields.FRONT_BACK = { + name: 'FRONT_BACK', + value: 'front' + }; + break; + case 'goBackByLayers:': + activeBlock.fields.FORWARD_BACKWARD = { + name: 'FORWARD_BACKWARD', + value: 'backward' + }; + break; + case 'backgroundIndex': + activeBlock.fields.NUMBER_NAME = { + name: 'NUMBER_NAME', + value: 'number' + }; + break; + case 'sceneName': + activeBlock.fields.NUMBER_NAME = { + name: 'NUMBER_NAME', + value: 'name' + }; + break; + case 'costumeIndex': + activeBlock.fields.NUMBER_NAME = { + name: 'NUMBER_NAME', + value: 'number' + }; + break; + } + + // Special cases to generate mutations. + if (oldOpcode === 'stopScripts') { + // Mutation for stop block: if the argument is 'other scripts', + // the block needs a next connection. + if (sb2block[1] === 'other scripts in sprite' || + sb2block[1] === 'other scripts in stage') { + activeBlock.mutation = { + tagName: 'mutation', + hasnext: 'true', + children: [] + }; + } + } else if (oldOpcode === 'procDef') { + // Mutation for procedure definition: + // store all 2.0 proc data. + const procData = sb2block.slice(1); + // Create a new block and input metadata. + const inputUid = uid(); + const inputName = 'custom_block'; + activeBlock.inputs[inputName] = { + name: inputName, + block: inputUid, + shadow: inputUid + }; + activeBlock.children = [{ + id: inputUid, + opcode: 'procedures_prototype', + inputs: {}, + fields: {}, + next: null, + shadow: true, + children: [], + mutation: { + tagName: 'mutation', + proccode: procData[0], // e.g., "abc %n %b %s" + argumentnames: JSON.stringify(procData[1]), // e.g. ['arg1', 'arg2'] + argumentids: JSON.stringify(parseProcedureArgIds(procData[0])), + argumentdefaults: JSON.stringify(procData[2]), // e.g., [1, 'abc'] + warp: procData[3], // Warp mode, e.g., true/false. + children: [] + } + }]; + } else if (oldOpcode === 'call') { + // Mutation for procedure call: + // string for proc code (e.g., "abc %n %b %s"). + activeBlock.mutation = { + tagName: 'mutation', + children: [], + proccode: sb2block[1], + argumentids: JSON.stringify(parseProcedureArgIds(sb2block[1])) + }; + } else if (oldOpcode === 'getParam') { + // Assign correct opcode based on the block shape. + switch (sb2block[2]) { + case 'r': + activeBlock.opcode = 'argument_reporter_string_number'; + break; + case 'b': + activeBlock.opcode = 'argument_reporter_boolean'; + break; + } + } + return activeBlock; +}; + +module.exports = { + deserialize: sb2import +}; diff --git a/src/serialization/sb2_specmap.js b/src/serialization/sb2_specmap.js new file mode 100644 index 00000000..e9b56b9d --- /dev/null +++ b/src/serialization/sb2_specmap.js @@ -0,0 +1,1597 @@ +/** + * @fileoverview + * The specMap below handles a few pieces of "translation" work between + * the SB2 JSON format and the data we need to run a project + * in the Scratch 3.0 VM. + * Notably: + * - Map 2.0 and 1.4 opcodes (forward:) into 3.0-format (motion_movesteps). + * - Map ordered, unnamed args to unordered, named inputs and fields. + * Keep this up-to-date as 3.0 blocks are renamed, changed, etc. + * Originally this was generated largely by a hand-guided scripting process. + * The relevant data lives here: + * https://github.com/LLK/scratch-flash/blob/master/src/Specs.as + * (for the old opcode and argument order). + * and here: + * https://github.com/LLK/scratch-blocks/tree/develop/blocks_vertical + * (for the new opcodes and argument names). + * and here: + * https://github.com/LLK/scratch-blocks/blob/develop/tests/ + * (for the shadow blocks created for each block). + * I started with the `commands` array in Specs.as, and discarded irrelevant + * properties. By hand, I matched the opcode name to the 3.0 opcode. + * Finally, I filled in the expected arguments as below. + */ + +const Variable = require('../engine/variable'); + +/** + * @typedef {object} SB2SpecMap_blockInfo + * @property {string} opcode - the Scratch 3.0 block opcode. Use 'extensionID.opcode' for extension opcodes. + * @property {Array.} argMap - metadata for this block's arguments. + */ + +/** + * @typedef {object} SB2SpecMap_argInfo + * @property {string} type - the type of this arg (such as 'input' or 'field') + * @property {string} inputOp - the scratch-blocks shadow type for this arg + * @property {string} inputName - the name this argument will take when provided to the block implementation + */ + +/** + * Mapping of Scratch 2.0 opcode to Scratch 3.0 block metadata. + * @type {object.} + */ +const specMap = { + 'forward:': { + opcode: 'motion_movesteps', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'STEPS' + } + ] + }, + 'turnRight:': { + opcode: 'motion_turnright', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'DEGREES' + } + ] + }, + 'turnLeft:': { + opcode: 'motion_turnleft', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'DEGREES' + } + ] + }, + 'heading:': { + opcode: 'motion_pointindirection', + argMap: [ + { + type: 'input', + inputOp: 'math_angle', + inputName: 'DIRECTION' + } + ] + }, + 'pointTowards:': { + opcode: 'motion_pointtowards', + argMap: [ + { + type: 'input', + inputOp: 'motion_pointtowards_menu', + inputName: 'TOWARDS' + } + ] + }, + 'gotoX:y:': { + opcode: 'motion_gotoxy', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'X' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'Y' + } + ] + }, + 'gotoSpriteOrMouse:': { + opcode: 'motion_goto', + argMap: [ + { + type: 'input', + inputOp: 'motion_goto_menu', + inputName: 'TO' + } + ] + }, + 'glideSecs:toX:y:elapsed:from:': { + opcode: 'motion_glidesecstoxy', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SECS' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'X' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'Y' + } + ] + }, + 'changeXposBy:': { + opcode: 'motion_changexby', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'DX' + } + ] + }, + 'xpos:': { + opcode: 'motion_setx', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'X' + } + ] + }, + 'changeYposBy:': { + opcode: 'motion_changeyby', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'DY' + } + ] + }, + 'ypos:': { + opcode: 'motion_sety', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'Y' + } + ] + }, + 'bounceOffEdge': { + opcode: 'motion_ifonedgebounce', + argMap: [ + ] + }, + 'setRotationStyle': { + opcode: 'motion_setrotationstyle', + argMap: [ + { + type: 'field', + fieldName: 'STYLE' + } + ] + }, + 'xpos': { + opcode: 'motion_xposition', + argMap: [ + ] + }, + 'ypos': { + opcode: 'motion_yposition', + argMap: [ + ] + }, + 'heading': { + opcode: 'motion_direction', + argMap: [ + ] + }, + 'say:duration:elapsed:from:': { + opcode: 'looks_sayforsecs', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MESSAGE' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'SECS' + } + ] + }, + 'say:': { + opcode: 'looks_say', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MESSAGE' + } + ] + }, + 'think:duration:elapsed:from:': { + opcode: 'looks_thinkforsecs', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MESSAGE' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'SECS' + } + ] + }, + 'think:': { + opcode: 'looks_think', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MESSAGE' + } + ] + }, + 'show': { + opcode: 'looks_show', + argMap: [ + ] + }, + 'hide': { + opcode: 'looks_hide', + argMap: [ + ] + }, + 'lookLike:': { + opcode: 'looks_switchcostumeto', + argMap: [ + { + type: 'input', + inputOp: 'looks_costume', + inputName: 'COSTUME' + } + ] + }, + 'nextCostume': { + opcode: 'looks_nextcostume', + argMap: [ + ] + }, + 'startScene': { + opcode: 'looks_switchbackdropto', + argMap: [ + { + type: 'input', + inputOp: 'looks_backdrops', + inputName: 'BACKDROP' + } + ] + }, + 'changeGraphicEffect:by:': { + opcode: 'looks_changeeffectby', + argMap: [ + { + type: 'field', + fieldName: 'EFFECT' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'CHANGE' + } + ] + }, + 'setGraphicEffect:to:': { + opcode: 'looks_seteffectto', + argMap: [ + { + type: 'field', + fieldName: 'EFFECT' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'VALUE' + } + ] + }, + 'filterReset': { + opcode: 'looks_cleargraphiceffects', + argMap: [ + ] + }, + 'changeSizeBy:': { + opcode: 'looks_changesizeby', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'CHANGE' + } + ] + }, + 'setSizeTo:': { + opcode: 'looks_setsizeto', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SIZE' + } + ] + }, + 'comeToFront': { + opcode: 'looks_gotofrontback', + argMap: [ + ] + }, + 'goBackByLayers:': { + opcode: 'looks_goforwardbackwardlayers', + argMap: [ + { + type: 'input', + inputOp: 'math_integer', + inputName: 'NUM' + } + ] + }, + 'costumeIndex': { + opcode: 'looks_costumenumbername', + argMap: [ + ] + }, + 'sceneName': { + opcode: 'looks_backdropnumbername', + argMap: [ + ] + }, + 'scale': { + opcode: 'looks_size', + argMap: [ + ] + }, + 'startSceneAndWait': { + opcode: 'looks_switchbackdroptoandwait', + argMap: [ + { + type: 'input', + inputOp: 'looks_backdrops', + inputName: 'BACKDROP' + } + ] + }, + 'nextScene': { + opcode: 'looks_nextbackdrop', + argMap: [ + ] + }, + 'backgroundIndex': { + opcode: 'looks_backdropnumbername', + argMap: [ + ] + }, + 'playSound:': { + opcode: 'sound_play', + argMap: [ + { + type: 'input', + inputOp: 'sound_sounds_menu', + inputName: 'SOUND_MENU' + } + ] + }, + 'doPlaySoundAndWait': { + opcode: 'sound_playuntildone', + argMap: [ + { + type: 'input', + inputOp: 'sound_sounds_menu', + inputName: 'SOUND_MENU' + } + ] + }, + 'stopAllSounds': { + opcode: 'sound_stopallsounds', + argMap: [ + ] + }, + 'playDrum': { + opcode: 'music.playDrumForBeats', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'DRUM' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'BEATS' + } + ] + }, + 'rest:elapsed:from:': { + opcode: 'music.restForBeats', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'BEATS' + } + ] + }, + 'noteOn:duration:elapsed:from:': { + opcode: 'music.playNoteForBeats', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NOTE' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'BEATS' + } + ] + }, + 'instrument:': { + opcode: 'music.setInstrument', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'INSTRUMENT' + } + ] + }, + 'changeVolumeBy:': { + opcode: 'sound_changevolumeby', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'VOLUME' + } + ] + }, + 'setVolumeTo:': { + opcode: 'sound_setvolumeto', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'VOLUME' + } + ] + }, + 'volume': { + opcode: 'sound_volume', + argMap: [ + ] + }, + 'changeTempoBy:': { + opcode: 'music.changeTempo', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'TEMPO' + } + ] + }, + 'setTempoTo:': { + opcode: 'music.setTempo', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'TEMPO' + } + ] + }, + 'tempo': { + opcode: 'music.getTempo', + argMap: [ + ] + }, + 'clearPenTrails': { + opcode: 'pen.clear', + argMap: [ + ] + }, + 'stampCostume': { + opcode: 'pen.stamp', + argMap: [ + ] + }, + 'putPenDown': { + opcode: 'pen.penDown', + argMap: [ + ] + }, + 'putPenUp': { + opcode: 'pen.penUp', + argMap: [ + ] + }, + 'penColor:': { + opcode: 'pen.setPenColorToColor', + argMap: [ + { + type: 'input', + inputOp: 'colour_picker', + inputName: 'COLOR' + } + ] + }, + 'changePenHueBy:': { + opcode: 'pen.changePenHueBy', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'HUE' + } + ] + }, + 'setPenHueTo:': { + opcode: 'pen.setPenHueToNumber', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'HUE' + } + ] + }, + 'changePenShadeBy:': { + opcode: 'pen.changePenShadeBy', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SHADE' + } + ] + }, + 'setPenShadeTo:': { + opcode: 'pen.setPenShadeToNumber', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SHADE' + } + ] + }, + 'changePenSizeBy:': { + opcode: 'pen.changePenSizeBy', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SIZE' + } + ] + }, + 'penSize:': { + opcode: 'pen.setPenSizeTo', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'SIZE' + } + ] + }, + 'whenGreenFlag': { + opcode: 'event_whenflagclicked', + argMap: [ + ] + }, + 'whenKeyPressed': { + opcode: 'event_whenkeypressed', + argMap: [ + { + type: 'field', + fieldName: 'KEY_OPTION' + } + ] + }, + 'whenClicked': { + opcode: 'event_whenthisspriteclicked', + argMap: [ + ] + }, + 'whenSceneStarts': { + opcode: 'event_whenbackdropswitchesto', + argMap: [ + { + type: 'field', + fieldName: 'BACKDROP' + } + ] + }, + 'whenSensorGreaterThan': { + opcode: 'event_whengreaterthan', + argMap: [ + { + type: 'field', + fieldName: 'WHENGREATERTHANMENU' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'VALUE' + } + ] + }, + 'whenIReceive': { + opcode: 'event_whenbroadcastreceived', + argMap: [ + { + type: 'field', + fieldName: 'BROADCAST_OPTION', + variableType: Variable.BROADCAST_MESSAGE_TYPE + } + ] + }, + 'broadcast:': { + opcode: 'event_broadcast', + argMap: [ + { + type: 'input', + inputOp: 'event_broadcast_menu', + inputName: 'BROADCAST_INPUT', + variableType: Variable.BROADCAST_MESSAGE_TYPE + } + ] + }, + 'doBroadcastAndWait': { + opcode: 'event_broadcastandwait', + argMap: [ + { + type: 'input', + inputOp: 'event_broadcast_menu', + inputName: 'BROADCAST_INPUT', + variableType: Variable.BROADCAST_MESSAGE_TYPE + } + ] + }, + 'wait:elapsed:from:': { + opcode: 'control_wait', + argMap: [ + { + type: 'input', + inputOp: 'math_positive_number', + inputName: 'DURATION' + } + ] + }, + 'doRepeat': { + opcode: 'control_repeat', + argMap: [ + { + type: 'input', + inputOp: 'math_whole_number', + inputName: 'TIMES' + }, + { + type: 'input', + inputName: 'SUBSTACK' + } + ] + }, + 'doForever': { + opcode: 'control_forever', + argMap: [ + { + type: 'input', + inputName: 'SUBSTACK' + } + ] + }, + 'doIf': { + opcode: 'control_if', + argMap: [ + { + type: 'input', + inputName: 'CONDITION' + }, + { + type: 'input', + inputName: 'SUBSTACK' + } + ] + }, + 'doIfElse': { + opcode: 'control_if_else', + argMap: [ + { + type: 'input', + inputName: 'CONDITION' + }, + { + type: 'input', + inputName: 'SUBSTACK' + }, + { + type: 'input', + inputName: 'SUBSTACK2' + } + ] + }, + 'doWaitUntil': { + opcode: 'control_wait_until', + argMap: [ + { + type: 'input', + inputName: 'CONDITION' + } + ] + }, + 'doUntil': { + opcode: 'control_repeat_until', + argMap: [ + { + type: 'input', + inputName: 'CONDITION' + }, + { + type: 'input', + inputName: 'SUBSTACK' + } + ] + }, + 'stopScripts': { + opcode: 'control_stop', + argMap: [ + { + type: 'field', + fieldName: 'STOP_OPTION' + } + ] + }, + 'whenCloned': { + opcode: 'control_start_as_clone', + argMap: [ + ] + }, + 'createCloneOf': { + opcode: 'control_create_clone_of', + argMap: [ + { + type: 'input', + inputOp: 'control_create_clone_of_menu', + inputName: 'CLONE_OPTION' + } + ] + }, + 'deleteClone': { + opcode: 'control_delete_this_clone', + argMap: [ + ] + }, + 'touching:': { + opcode: 'sensing_touchingobject', + argMap: [ + { + type: 'input', + inputOp: 'sensing_touchingobjectmenu', + inputName: 'TOUCHINGOBJECTMENU' + } + ] + }, + 'touchingColor:': { + opcode: 'sensing_touchingcolor', + argMap: [ + { + type: 'input', + inputOp: 'colour_picker', + inputName: 'COLOR' + } + ] + }, + 'color:sees:': { + opcode: 'sensing_coloristouchingcolor', + argMap: [ + { + type: 'input', + inputOp: 'colour_picker', + inputName: 'COLOR' + }, + { + type: 'input', + inputOp: 'colour_picker', + inputName: 'COLOR2' + } + ] + }, + 'distanceTo:': { + opcode: 'sensing_distanceto', + argMap: [ + { + type: 'input', + inputOp: 'sensing_distancetomenu', + inputName: 'DISTANCETOMENU' + } + ] + }, + 'doAsk': { + opcode: 'sensing_askandwait', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'QUESTION' + } + ] + }, + 'answer': { + opcode: 'sensing_answer', + argMap: [ + ] + }, + 'keyPressed:': { + opcode: 'sensing_keypressed', + argMap: [ + { + type: 'field', + fieldName: 'KEY_OPTION' + } + ] + }, + 'mousePressed': { + opcode: 'sensing_mousedown', + argMap: [ + ] + }, + 'mouseX': { + opcode: 'sensing_mousex', + argMap: [ + ] + }, + 'mouseY': { + opcode: 'sensing_mousey', + argMap: [ + ] + }, + 'soundLevel': { + opcode: 'sensing_loudness', + argMap: [ + ] + }, + 'senseVideoMotion': { + opcode: 'sensing_videoon', + argMap: [ + { + type: 'input', + inputOp: 'sensing_videoonmenuone', + inputName: 'VIDEOONMENU1' + }, + { + type: 'input', + inputOp: 'sensing_videoonmenutwo', + inputName: 'VIDEOONMENU2' + } + ] + }, + 'setVideoState': { + opcode: 'sensing_videotoggle', + argMap: [ + { + type: 'input', + inputOp: 'sensing_videotogglemenu', + inputName: 'VIDEOTOGGLEMENU' + } + ] + }, + 'setVideoTransparency': { + opcode: 'sensing_setvideotransparency', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'TRANSPARENCY' + } + ] + }, + 'timer': { + opcode: 'sensing_timer', + argMap: [ + ] + }, + 'timerReset': { + opcode: 'sensing_resettimer', + argMap: [ + ] + }, + 'getAttribute:of:': { + opcode: 'sensing_of', + argMap: [ + { + type: 'field', + fieldName: 'PROPERTY' + }, + { + type: 'input', + inputOp: 'sensing_of_object_menu', + inputName: 'OBJECT' + } + ] + }, + 'timeAndDate': { + opcode: 'sensing_current', + argMap: [ + { + type: 'input', + inputOp: 'sensing_currentmenu', + inputName: 'CURRENTMENU' + } + ] + }, + 'timestamp': { + opcode: 'sensing_dayssince2000', + argMap: [ + ] + }, + 'getUserName': { + opcode: 'sensing_username', + argMap: [ + ] + }, + '+': { + opcode: 'operator_add', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM1' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM2' + } + ] + }, + '-': { + opcode: 'operator_subtract', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM1' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM2' + } + ] + }, + '*': { + opcode: 'operator_multiply', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM1' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM2' + } + ] + }, + '/': { + opcode: 'operator_divide', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM1' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM2' + } + ] + }, + 'randomFrom:to:': { + opcode: 'operator_random', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'FROM' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'TO' + } + ] + }, + '<': { + opcode: 'operator_lt', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND1' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND2' + } + ] + }, + '=': { + opcode: 'operator_equals', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND1' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND2' + } + ] + }, + '>': { + opcode: 'operator_gt', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND1' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'OPERAND2' + } + ] + }, + '&': { + opcode: 'operator_and', + argMap: [ + { + type: 'input', + inputName: 'OPERAND1' + }, + { + type: 'input', + inputName: 'OPERAND2' + } + ] + }, + '|': { + opcode: 'operator_or', + argMap: [ + { + type: 'input', + inputName: 'OPERAND1' + }, + { + type: 'input', + inputName: 'OPERAND2' + } + ] + }, + 'not': { + opcode: 'operator_not', + argMap: [ + { + type: 'input', + inputName: 'OPERAND' + } + ] + }, + 'concatenate:with:': { + opcode: 'operator_join', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'STRING1' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'STRING2' + } + ] + }, + 'letter:of:': { + opcode: 'operator_letter_of', + argMap: [ + { + type: 'input', + inputOp: 'math_whole_number', + inputName: 'LETTER' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'STRING' + } + ] + }, + 'stringLength:': { + opcode: 'operator_length', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'STRING' + } + ] + }, + '%': { + opcode: 'operator_mod', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM1' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM2' + } + ] + }, + 'rounded': { + opcode: 'operator_round', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM' + } + ] + }, + 'computeFunction:of:': { + opcode: 'operator_mathop', + argMap: [ + { + type: 'field', + fieldName: 'OPERATOR' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'NUM' + } + ] + }, + 'readVariable': { + opcode: 'data_variable', + argMap: [ + { + type: 'field', + fieldName: 'VARIABLE', + variableType: Variable.SCALAR_TYPE + } + ] + }, + 'setVar:to:': { + opcode: 'data_setvariableto', + argMap: [ + { + type: 'field', + fieldName: 'VARIABLE', + variableType: Variable.SCALAR_TYPE + }, + { + type: 'input', + inputOp: 'text', + inputName: 'VALUE' + } + ] + }, + 'changeVar:by:': { + opcode: 'data_changevariableby', + argMap: [ + { + type: 'field', + fieldName: 'VARIABLE', + variableType: Variable.SCALAR_TYPE + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'VALUE' + } + ] + }, + 'showVariable:': { + opcode: 'data_showvariable', + argMap: [ + { + type: 'field', + fieldName: 'VARIABLE', + variableType: Variable.SCALAR_TYPE + } + ] + }, + 'hideVariable:': { + opcode: 'data_hidevariable', + argMap: [ + { + type: 'field', + fieldName: 'VARIABLE', + variableType: Variable.SCALAR_TYPE + } + ] + }, + 'contentsOfList:': { + opcode: 'data_listcontents', + argMap: [ + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'append:toList:': { + opcode: 'data_addtolist', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'ITEM' + }, + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'deleteLine:ofList:': { + opcode: 'data_deleteoflist', + argMap: [ + { + type: 'input', + inputOp: 'math_integer', + inputName: 'INDEX' + }, + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'insert:at:ofList:': { + opcode: 'data_insertatlist', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'ITEM' + }, + { + type: 'input', + inputOp: 'math_integer', + inputName: 'INDEX' + }, + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'setLine:ofList:to:': { + opcode: 'data_replaceitemoflist', + argMap: [ + { + type: 'input', + inputOp: 'math_integer', + inputName: 'INDEX' + }, + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + }, + { + type: 'input', + inputOp: 'text', + inputName: 'ITEM' + } + ] + }, + 'getLine:ofList:': { + opcode: 'data_itemoflist', + argMap: [ + { + type: 'input', + inputOp: 'math_integer', + inputName: 'INDEX' + }, + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'lineCountOfList:': { + opcode: 'data_lengthoflist', + argMap: [ + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'list:contains:': { + opcode: 'data_listcontainsitem', + argMap: [ + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + }, + { + type: 'input', + inputOp: 'text', + inputName: 'ITEM' + } + ] + }, + 'showList:': { + opcode: 'data_showlist', + argMap: [ + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'hideList:': { + opcode: 'data_hidelist', + argMap: [ + { + type: 'field', + fieldName: 'LIST', + variableType: Variable.LIST_TYPE + } + ] + }, + 'procDef': { + opcode: 'procedures_definition', + argMap: [] + }, + 'getParam': { + // Doesn't map to single opcode. Import step assigns final correct opcode. + opcode: 'argument_reporter_string_number', + argMap: [ + { + type: 'field', + fieldName: 'VALUE' + } + ] + }, + 'call': { + opcode: 'procedures_call', + argMap: [] + } +}; + +/** + * Add to the specMap entries for an opcode from a Scratch 2.0 extension. Two entries will be made with the same + * metadata; this is done to support projects saved by both older and newer versions of the Scratch 2.0 editor. + * @param {string} sb2Extension - the Scratch 2.0 name of the extension + * @param {string} sb2Opcode - the Scratch 2.0 opcode + * @param {SB2SpecMap_blockInfo} blockInfo - the Scratch 3.0 block info + */ +const addExtensionOp = function (sb2Extension, sb2Opcode, blockInfo) { + /** + * This string separates the name of an extension and the name of an opcode in more recent Scratch 2.0 projects. + * Earlier projects used '.' as a separator, up until we added the 'LEGO WeDo 2.0' extension... + * @type {string} + */ + const sep = '\u001F'; // Unicode Unit Separator + + // make one entry for projects saved by recent versions of the Scratch 2.0 editor + specMap[`${sb2Extension}${sep}${sb2Opcode}`] = blockInfo; + + // make a second for projects saved by older versions of the Scratch 2.0 editor + specMap[`${sb2Extension}.${sb2Opcode}`] = blockInfo; +}; + +const weDo2 = 'LEGO WeDo 2.0'; + +addExtensionOp(weDo2, 'motorOnFor', { + opcode: 'wedo2.motorOnFor', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.motorID', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'DURATION' + } + ] +}); + +addExtensionOp(weDo2, 'motorOn', { + opcode: 'wedo2.motorOn', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.motorID', + inputName: 'MOTOR_ID' + } + ] +}); + +addExtensionOp(weDo2, 'motorOff', { + opcode: 'wedo2.motorOff', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.motorID', + inputName: 'MOTOR_ID' + } + ] +}); + +addExtensionOp(weDo2, 'startMotorPower', { + opcode: 'wedo2.startMotorPower', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.motorID', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'POWER' + } + ] +}); + +addExtensionOp(weDo2, 'setMotorDirection', { + opcode: 'wedo2.setMotorDirection', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.motorID', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'wedo2.menu.motorDirection', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'setLED', { + opcode: 'wedo2.setLightHue', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'HUE' + } + ] +}); + +addExtensionOp(weDo2, 'playNote', { + opcode: 'wedo2.playNoteFor', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NOTE' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'DURATION' + } + ] +}); + +addExtensionOp(weDo2, 'whenDistance', { + opcode: 'wedo2.whenDistance', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.lessMore', + inputName: 'OP' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'REFERENCE' + } + ] +}); + +addExtensionOp(weDo2, 'whenTilted', { + opcode: 'wedo2.whenTilted', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.tiltDirectionAny', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'getDistance', { + opcode: 'wedo2.getDistance', + argMap: [] +}); + +addExtensionOp(weDo2, 'isTilted', { + opcode: 'wedo2.isTilted', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.tiltDirectionAny', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'getTilt', { + opcode: 'wedo2.getTiltAngle', + argMap: [ + { + type: 'input', + inputOp: 'wedo2.menu.tiltDirection', + inputName: 'DIRECTION' + } + ] +}); + +module.exports = specMap; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js new file mode 100644 index 00000000..9e8bcbf4 --- /dev/null +++ b/src/serialization/sb3.js @@ -0,0 +1,190 @@ +/** + * @fileoverview + * Partial implementation of a SB3 serializer and deserializer. Parses provided + * JSON and then generates all needed scratch-vm runtime structures. + */ + +const vmPackage = require('../../package.json'); +const Blocks = require('../engine/blocks'); +const Sprite = require('../sprites/sprite'); +const Variable = require('../engine/variable'); + +const {loadCostume} = require('../import/load-costume.js'); +const {loadSound} = require('../import/load-sound.js'); + +/** + * @typedef {object} ImportedProject + * @property {Array.} targets - the imported Scratch 3.0 target objects. + * @property {ImportedExtensionsInfo} extensionsInfo - the ID of each extension actually used by this project. + */ + +/** + * @typedef {object} ImportedExtensionsInfo + * @property {Set.} extensionIDs - the ID of each extension actually in use by blocks in this project. + * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. + */ + +/** + * Serializes the specified VM runtime. + * @param {!Runtime} runtime VM runtime instance to be serialized. + * @return {object} Serialized runtime instance. + */ +const serialize = function (runtime) { + // Fetch targets + const obj = Object.create(null); + obj.targets = runtime.targets.filter(target => target.isOriginal); + + // Assemble metadata + const meta = Object.create(null); + meta.semver = '3.0.0'; + meta.vm = vmPackage.version; + + // Attach full user agent string to metadata if available + meta.agent = null; + if (typeof navigator !== 'undefined') meta.agent = navigator.userAgent; + + // Assemble payload and return + obj.meta = meta; + return obj; +}; + +/** + * Parse a single "Scratch object" and create all its in-memory VM objects. + * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. + * @param {!Runtime} runtime Runtime object to load all structures into. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + */ +const parseScratchObject = function (object, runtime, extensions) { + if (!object.hasOwnProperty('name')) { + // Watcher/monitor - skip this object until those are implemented in VM. + // @todo + return Promise.resolve(null); + } + // Blocks container for this object. + const blocks = new Blocks(); + + // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. + const sprite = new Sprite(blocks, runtime); + + // Sprite/stage name from JSON. + if (object.hasOwnProperty('name')) { + sprite.name = object.name; + } + if (object.hasOwnProperty('blocks')) { + for (const blockId in object.blocks) { + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + + const dotIndex = blockJSON.opcode.indexOf('.'); + if (dotIndex >= 0) { + const extensionId = blockJSON.opcode.substring(0, dotIndex); + extensions.extensionIDs.add(extensionId); + } + } + // console.log(blocks); + } + // Costumes from JSON. + const costumePromises = (object.costumes || []).map(costumeSource => { + // @todo: Make sure all the relevant metadata is being pulled out. + const costume = { + skinId: null, + name: costumeSource.name, + bitmapResolution: costumeSource.bitmapResolution, + rotationCenterX: costumeSource.rotationCenterX, + rotationCenterY: costumeSource.rotationCenterY + }; + const dataFormat = + costumeSource.dataFormat || + (costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format + 'png'; // if all else fails, guess that it might be a PNG + const costumeMd5 = `${costumeSource.assetId}.${dataFormat}`; + return loadCostume(costumeMd5, costume, runtime); + }); + // Sounds from JSON + const soundPromises = (object.sounds || []).map(soundSource => { + const sound = { + format: soundSource.format, + fileUrl: soundSource.fileUrl, + rate: soundSource.rate, + sampleCount: soundSource.sampleCount, + soundID: soundSource.soundID, + name: soundSource.name, + md5: soundSource.md5, + data: null + }; + return loadSound(sound, runtime); + }); + // Create the first clone, and load its run-state from JSON. + const target = sprite.createClone(); + // Load target properties from JSON. + if (object.hasOwnProperty('variables')) { + for (const j in object.variables) { + const variable = object.variables[j]; + const newVariable = new Variable( + variable.id, + variable.name, + variable.type, + variable.isPersistent + ); + newVariable.value = variable.value; + target.variables[newVariable.id] = newVariable; + } + } + if (object.hasOwnProperty('x')) { + target.x = object.x; + } + if (object.hasOwnProperty('y')) { + target.y = object.y; + } + if (object.hasOwnProperty('direction')) { + target.direction = object.direction; + } + if (object.hasOwnProperty('size')) { + target.size = object.size; + } + if (object.hasOwnProperty('visible')) { + target.visible = object.visible; + } + if (object.hasOwnProperty('currentCostume')) { + target.currentCostume = object.currentCostume; + } + if (object.hasOwnProperty('rotationStyle')) { + target.rotationStyle = object.rotationStyle; + } + if (object.hasOwnProperty('isStage')) { + target.isStage = object.isStage; + } + Promise.all(costumePromises).then(costumes => { + sprite.costumes = costumes; + }); + Promise.all(soundPromises).then(sounds => { + sprite.sounds = sounds; + }); + return Promise.all(costumePromises.concat(soundPromises)).then(() => target); +}; + +/** + * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. + * TODO: parse extension info (also, design extension info storage...) + * @param {object} json - JSON representation of a VM runtime. + * @param {Runtime} runtime - Runtime instance + * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized + */ +const deserialize = function (json, runtime) { + const extensions = { + extensionIDs: new Set(), + extensionURLs: new Map() + }; + return Promise.all( + (json.targets || []).map(target => parseScratchObject(target, runtime, extensions)) + ).then(targets => ({ + targets, + extensions + })); +}; + +module.exports = { + serialize: serialize, + deserialize: deserialize +}; diff --git a/src/sprites/clone.js b/src/sprites/clone.js deleted file mode 100644 index 13d9434a..00000000 --- a/src/sprites/clone.js +++ /dev/null @@ -1,588 +0,0 @@ -var util = require('util'); -var MathUtil = require('../util/math-util'); -var Target = require('../engine/target'); - -/** - * Clone (instance) of a sprite. - * @param {!Sprite} sprite Reference to the sprite. - * @param {Runtime} runtime Reference to the runtime. - * @constructor - */ -function Clone(sprite, runtime) { - Target.call(this, sprite.blocks); - this.runtime = runtime; - /** - * Reference to the sprite that this is a clone of. - * @type {!Sprite} - */ - this.sprite = sprite; - /** - * Reference to the global renderer for this VM, if one exists. - * @type {?RenderWebGLWorker} - */ - this.renderer = null; - if (this.runtime) { - this.renderer = this.runtime.renderer; - } - /** - * ID of the drawable for this clone returned by the renderer, if rendered. - * @type {?Number} - */ - this.drawableID = null; - - /** - * Map of current graphic effect values. - * @type {!Object.} - */ - this.effects = { - 'color': 0, - 'fisheye': 0, - 'whirl': 0, - 'pixelate': 0, - 'mosaic': 0, - 'brightness': 0, - 'ghost': 0 - }; -} -util.inherits(Clone, Target); - -/** - * Create a clone's drawable with the this.renderer. - */ -Clone.prototype.initDrawable = function () { - if (this.renderer) { - this.drawableID = this.renderer.createDrawable(); - } - // If we're a clone, start the hats. - if (!this.isOriginal) { - this.runtime.startHats( - 'control_start_as_clone', null, this - ); - } -}; - -// Clone-level properties. -/** - * Whether this represents an "original" clone, i.e., created by the editor - * and not clone blocks. In interface terms, this true for a "sprite." - * @type {boolean} - */ -Clone.prototype.isOriginal = true; - -/** - * Whether this clone represents the Scratch stage. - * @type {boolean} - */ -Clone.prototype.isStage = false; - -/** - * Scratch X coordinate. Currently should range from -240 to 240. - * @type {Number} - */ -Clone.prototype.x = 0; - -/** - * Scratch Y coordinate. Currently should range from -180 to 180. - * @type {number} - */ -Clone.prototype.y = 0; - -/** - * Scratch direction. Currently should range from -179 to 180. - * @type {number} - */ -Clone.prototype.direction = 90; - -/** - * Whether the clone is currently visible. - * @type {boolean} - */ -Clone.prototype.visible = true; - -/** - * Size of clone as a percent of costume size. Ranges from 5% to 535%. - * @type {number} - */ -Clone.prototype.size = 100; - -/** - * Currently selected costume index. - * @type {number} - */ -Clone.prototype.currentCostume = 0; - -/** - * Rotation style for "all around"/spinning. - * @enum - */ -Clone.ROTATION_STYLE_ALL_AROUND = 'all around'; - -/** - * Rotation style for "left-right"/flipping. - * @enum - */ -Clone.ROTATION_STYLE_LEFT_RIGHT = 'left-right'; - -/** - * Rotation style for "no rotation." - * @enum - */ -Clone.ROTATION_STYLE_NONE = 'don\'t rotate'; - -/** - * Current rotation style. - * @type {!string} - */ -Clone.prototype.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND; - -// End clone-level properties. - -/** - * Set the X and Y coordinates of a clone. - * @param {!number} x New X coordinate of clone, in Scratch coordinates. - * @param {!number} y New Y coordinate of clone, in Scratch coordinates. - */ -Clone.prototype.setXY = function (x, y) { - if (this.isStage) { - return; - } - this.x = x; - this.y = y; - if (this.renderer) { - this.renderer.updateDrawableProperties(this.drawableID, { - position: [this.x, this.y] - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Get the rendered direction and scale, after applying rotation style. - * @return {Object} Direction and scale to render. - */ -Clone.prototype._getRenderedDirectionAndScale = function () { - // Default: no changes to `this.direction` or `this.scale`. - var finalDirection = this.direction; - var finalScale = [this.size, this.size]; - if (this.rotationStyle == Clone.ROTATION_STYLE_NONE) { - // Force rendered direction to be 90. - finalDirection = 90; - } else if (this.rotationStyle === Clone.ROTATION_STYLE_LEFT_RIGHT) { - // Force rendered direction to be 90, and flip drawable if needed. - finalDirection = 90; - var scaleFlip = (this.direction < 0) ? -1 : 1; - finalScale = [scaleFlip * this.size, this.size]; - } - return {direction: finalDirection, scale: finalScale}; -}; - -/** - * Set the direction of a clone. - * @param {!number} direction New direction of clone. - */ -Clone.prototype.setDirection = function (direction) { - if (this.isStage) { - return; - } - // Keep direction between -179 and +180. - this.direction = MathUtil.wrapClamp(direction, -179, 180); - if (this.renderer) { - var renderedDirectionScale = this._getRenderedDirectionAndScale(); - this.renderer.updateDrawableProperties(this.drawableID, { - direction: renderedDirectionScale.direction, - scale: renderedDirectionScale.scale - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Set a say bubble on this clone. - * @param {?string} type Type of say bubble: "say", "think", or null. - * @param {?string} message Message to put in say bubble. - */ -Clone.prototype.setSay = function (type, message) { - if (this.isStage) { - return; - } - // @todo: Render to stage. - if (!type || !message) { - console.log('Clearing say bubble'); - return; - } - console.log('Setting say bubble:', type, message); -}; - -/** - * Set visibility of the clone; i.e., whether it's shown or hidden. - * @param {!boolean} visible True if the sprite should be shown. - */ -Clone.prototype.setVisible = function (visible) { - if (this.isStage) { - return; - } - this.visible = visible; - if (this.renderer) { - this.renderer.updateDrawableProperties(this.drawableID, { - visible: this.visible - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Set size of the clone, as a percentage of the costume size. - * @param {!number} size Size of clone, from 5 to 535. - */ -Clone.prototype.setSize = function (size) { - if (this.isStage) { - return; - } - // Keep size between 5% and 535%. - this.size = MathUtil.clamp(size, 5, 535); - if (this.renderer) { - var renderedDirectionScale = this._getRenderedDirectionAndScale(); - this.renderer.updateDrawableProperties(this.drawableID, { - direction: renderedDirectionScale.direction, - scale: renderedDirectionScale.scale - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Set a particular graphic effect on this clone. - * @param {!string} effectName Name of effect (see `Clone.prototype.effects`). - * @param {!number} value Numerical magnitude of effect. - */ -Clone.prototype.setEffect = function (effectName, value) { - if (!this.effects.hasOwnProperty(effectName)) return; - this.effects[effectName] = value; - if (this.renderer) { - var props = {}; - props[effectName] = this.effects[effectName]; - this.renderer.updateDrawableProperties(this.drawableID, props); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Clear all graphic effects on this clone. - */ -Clone.prototype.clearEffects = function () { - for (var effectName in this.effects) { - this.effects[effectName] = 0; - } - if (this.renderer) { - this.renderer.updateDrawableProperties(this.drawableID, this.effects); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Set the current costume of this clone. - * @param {number} index New index of costume. - */ -Clone.prototype.setCostume = function (index) { - // Keep the costume index within possible values. - index = Math.round(index); - this.currentCostume = MathUtil.wrapClamp( - index, 0, this.sprite.costumes.length - 1 - ); - if (this.renderer) { - this.renderer.updateDrawableProperties(this.drawableID, { - skin: this.sprite.costumes[this.currentCostume].skin - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Update the rotation style for this clone. - * @param {!string} rotationStyle New rotation style. - */ -Clone.prototype.setRotationStyle = function (rotationStyle) { - if (rotationStyle == Clone.ROTATION_STYLE_NONE) { - this.rotationStyle = Clone.ROTATION_STYLE_NONE; - } else if (rotationStyle == Clone.ROTATION_STYLE_ALL_AROUND) { - this.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND; - } else if (rotationStyle == Clone.ROTATION_STYLE_LEFT_RIGHT) { - this.rotationStyle = Clone.ROTATION_STYLE_LEFT_RIGHT; - } - if (this.renderer) { - var renderedDirectionScale = this._getRenderedDirectionAndScale(); - this.renderer.updateDrawableProperties(this.drawableID, { - direction: renderedDirectionScale.direction, - scale: renderedDirectionScale.scale - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Get a costume index of this clone, by name of the costume. - * @param {?string} costumeName Name of a costume. - * @return {number} Index of the named costume, or -1 if not present. - */ -Clone.prototype.getCostumeIndexByName = function (costumeName) { - for (var i = 0; i < this.sprite.costumes.length; i++) { - if (this.sprite.costumes[i].name == costumeName) { - return i; - } - } - return -1; -}; - -/** - * Update all drawable properties for this clone. - * Use when a batch has changed, e.g., when the drawable is first created. - */ -Clone.prototype.updateAllDrawableProperties = function () { - if (this.renderer) { - var renderedDirectionScale = this._getRenderedDirectionAndScale(); - this.renderer.updateDrawableProperties(this.drawableID, { - position: [this.x, this.y], - direction: renderedDirectionScale.direction, - scale: renderedDirectionScale.scale, - visible: this.visible, - skin: this.sprite.costumes[this.currentCostume].skin - }); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -/** - * Return the human-readable name for this clone, i.e., the sprite's name. - * @override - * @returns {string} Human-readable name for the clone. - */ -Clone.prototype.getName = function () { - return this.sprite.name; -}; - -/** - * Return the clone's tight bounding box. - * Includes top, left, bottom, right attributes in Scratch coordinates. - * @return {?Object} Tight bounding box of clone, or null. - */ -Clone.prototype.getBounds = function () { - if (this.renderer) { - return this.runtime.renderer.getBounds(this.drawableID); - } - return null; -}; - -/** - * Return whether the clone is touching a point. - * @param {number} x X coordinate of test point. - * @param {number} y Y coordinate of test point. - * @return {Boolean} True iff the clone is touching the point. - */ -Clone.prototype.isTouchingPoint = function (x, y) { - if (this.renderer) { - // @todo: Update once pick is in Scratch coordinates. - // Limits test to this Drawable, so this will return true - // even if the clone is obscured by another Drawable. - var pickResult = this.runtime.renderer.pick( - x + this.runtime.constructor.STAGE_WIDTH / 2, - -y + this.runtime.constructor.STAGE_HEIGHT / 2, - null, null, - [this.drawableID] - ); - return pickResult === this.drawableID; - } - return false; -}; - -/** - * Return whether the clone is touching a stage edge. - * @return {Boolean} True iff the clone is touching the stage edge. - */ -Clone.prototype.isTouchingEdge = function () { - if (this.renderer) { - var stageWidth = this.runtime.constructor.STAGE_WIDTH; - var stageHeight = this.runtime.constructor.STAGE_HEIGHT; - var bounds = this.getBounds(); - if (bounds.left < -stageWidth / 2 || - bounds.right > stageWidth / 2 || - bounds.top > stageHeight / 2 || - bounds.bottom < -stageHeight / 2) { - return true; - } - } - return false; -}; - -/** - * Return whether the clone is touching a named sprite. - * @param {string} spriteName Name fo the sprite. - * @return {Boolean} True iff the clone is touching a clone of the sprite. - */ -Clone.prototype.isTouchingSprite = function (spriteName) { - var firstClone = this.runtime.getSpriteTargetByName(spriteName); - if (!firstClone || !this.renderer) { - return false; - } - var drawableCandidates = firstClone.sprite.clones.map(function(clone) { - return clone.drawableID; - }); - return this.renderer.isTouchingDrawables( - this.drawableID, drawableCandidates); -}; - -/** - * Return whether the clone is touching a color. - * @param {Array.} rgb [r,g,b], values between 0-255. - * @return {Promise.} True iff the clone is touching the color. - */ -Clone.prototype.isTouchingColor = function (rgb) { - if (this.renderer) { - return this.renderer.isTouchingColor(this.drawableID, rgb); - } - return false; -}; - -/** - * Return whether the clone's color is touching a color. - * @param {Object} targetRgb {Array.} [r,g,b], values between 0-255. - * @param {Object} maskRgb {Array.} [r,g,b], values between 0-255. - * @return {Promise.} True iff the clone's color is touching the color. - */ -Clone.prototype.colorIsTouchingColor = function (targetRgb, maskRgb) { - if (this.renderer) { - return this.renderer.isTouchingColor( - this.drawableID, - targetRgb, - maskRgb - ); - } - return false; -}; - -/** - * Move clone to the front layer. - */ -Clone.prototype.goToFront = function () { - if (this.renderer) { - this.renderer.setDrawableOrder(this.drawableID, Infinity); - } -}; - -/** - * Move clone back a number of layers. - * @param {number} nLayers How many layers to go back. - */ -Clone.prototype.goBackLayers = function (nLayers) { - if (this.renderer) { - this.renderer.setDrawableOrder(this.drawableID, -nLayers, true, 1); - } -}; - -/** - * Keep a desired position within a fence. - * @param {number} newX New desired X position. - * @param {number} newY New desired Y position. - * @param {Object=} opt_fence Optional fence with left, right, top bottom. - * @return {Array.} Fenced X and Y coordinates. - */ -Clone.prototype.keepInFence = function (newX, newY, opt_fence) { - var fence = opt_fence; - if (!fence) { - fence = { - left: -this.runtime.constructor.STAGE_WIDTH / 2, - right: this.runtime.constructor.STAGE_WIDTH / 2, - top: this.runtime.constructor.STAGE_HEIGHT / 2, - bottom: -this.runtime.constructor.STAGE_HEIGHT / 2 - }; - } - var bounds = this.getBounds(); - if (!bounds) return; - // Adjust the known bounds to the target position. - bounds.left += (newX - this.x); - bounds.right += (newX - this.x); - bounds.top += (newY - this.y); - bounds.bottom += (newY - this.y); - // Find how far we need to move the target position. - var dx = 0; - var dy = 0; - if (bounds.left < fence.left) { - dx += fence.left - bounds.left; - } - if (bounds.right > fence.right) { - dx += fence.right - bounds.right; - } - if (bounds.top > fence.top) { - dy += fence.top - bounds.top; - } - if (bounds.bottom < fence.bottom) { - dy += fence.bottom - bounds.bottom; - } - return [newX + dx, newY + dy]; -}; - -/** - * Make a clone of this clone, copying any run-time properties. - * If we've hit the global clone limit, returns null. - * @return {!Clone} New clone object. - */ -Clone.prototype.makeClone = function () { - if (!this.runtime.clonesAvailable()) { - return; // Hit max clone limit. - } - this.runtime.changeCloneCounter(1); - var newClone = this.sprite.createClone(); - newClone.x = this.x; - newClone.y = this.y; - newClone.direction = this.direction; - newClone.visible = this.visible; - newClone.size = this.size; - newClone.currentCostume = this.currentCostume; - newClone.rotationStyle = this.rotationStyle; - newClone.effects = JSON.parse(JSON.stringify(this.effects)); - newClone.variables = JSON.parse(JSON.stringify(this.variables)); - newClone.lists = JSON.parse(JSON.stringify(this.lists)); - newClone.initDrawable(); - newClone.updateAllDrawableProperties(); - return newClone; -}; - -/** - * Called when the project receives a "green flag." - * For a clone, this clears graphic effects. - */ -Clone.prototype.onGreenFlag = function () { - this.clearEffects(); -}; - -/** - * Dispose of this clone, destroying any run-time properties. - */ -Clone.prototype.dispose = function () { - this.runtime.changeCloneCounter(-1); - if (this.renderer && this.drawableID !== null) { - this.renderer.destroyDrawable(this.drawableID); - if (this.visible) { - this.runtime.requestRedraw(); - } - } -}; - -module.exports = Clone; diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js new file mode 100644 index 00000000..515a30a0 --- /dev/null +++ b/src/sprites/rendered-target.js @@ -0,0 +1,948 @@ +const log = require('../util/log'); +const MathUtil = require('../util/math-util'); +const StringUtil = require('../util/string-util'); +const Target = require('../engine/target'); + +/** + * Rendered target: instance of a sprite (clone), or the stage. + */ +class RenderedTarget extends Target { + /** + * @param {!Sprite} sprite Reference to the parent sprite. + * @param {Runtime} runtime Reference to the runtime. + * @constructor + */ + constructor (sprite, runtime) { + super(runtime, sprite.blocks); + + /** + * Reference to the sprite that this is a render of. + * @type {!Sprite} + */ + this.sprite = sprite; + /** + * Reference to the global renderer for this VM, if one exists. + * @type {?RenderWebGL} + */ + this.renderer = null; + if (this.runtime) { + this.renderer = this.runtime.renderer; + } + /** + * ID of the drawable for this rendered target, + * returned by the renderer, if rendered. + * @type {?Number} + */ + this.drawableID = null; + + /** + * Drag state of this rendered target. If true, x/y position can't be + * changed by blocks. + * @type {boolean} + */ + this.dragging = false; + + /** + * Map of current graphic effect values. + * @type {!Object.} + */ + this.effects = { + color: 0, + fisheye: 0, + whirl: 0, + pixelate: 0, + mosaic: 0, + brightness: 0, + ghost: 0 + }; + + /** + * Whether this represents an "original" non-clone rendered-target for a sprite, + * i.e., created by the editor and not clone blocks. + * @type {boolean} + */ + this.isOriginal = true; + + /** + * Whether this rendered target represents the Scratch stage. + * @type {boolean} + */ + this.isStage = false; + + /** + * 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; + + /** + * Whether the rendered target is draggable on the stage + * @type {boolean} + */ + this.draggable = false; + + /** + * Whether the rendered target is currently visible. + * @type {boolean} + */ + this.visible = true; + + /** + * Size of rendered target as a percent of costume size. + * @type {number} + */ + this.size = 100; + + /** + * Currently selected costume index. + * @type {number} + */ + this.currentCostume = 0; + + /** + * Current rotation style. + * @type {!string} + */ + this.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND; + } + + /** + * Create a drawable with the this.renderer. + */ + initDrawable () { + if (this.renderer) { + this.drawableID = this.renderer.createDrawable(); + } + // If we're a clone, start the hats. + if (!this.isOriginal) { + this.runtime.startHats( + 'control_start_as_clone', null, this + ); + } + } + + /** + * Initialize the audio player for this sprite or clone. + */ + initAudio () { + this.audioPlayer = null; + if (this.runtime && this.runtime.audioEngine) { + this.audioPlayer = this.runtime.audioEngine.createPlayer(); + } + } + + /** + * Event which fires when a target moves. + * @type {string} + */ + static get EVENT_TARGET_MOVED () { + return 'TARGET_MOVED'; + } + + /** + * Rotation style for "all around"/spinning. + * @type {string} + */ + static get ROTATION_STYLE_ALL_AROUND () { + return 'all around'; + } + + /** + * Rotation style for "left-right"/flipping. + * @type {string} + */ + static get ROTATION_STYLE_LEFT_RIGHT () { + return 'left-right'; + } + + /** + * Rotation style for "no rotation." + * @type {string} + */ + static get ROTATION_STYLE_NONE () { + return "don't rotate"; + } + + /** + * Set the X and Y coordinates. + * @param {!number} x New X coordinate, in Scratch coordinates. + * @param {!number} y New Y coordinate, in Scratch coordinates. + * @param {?boolean} force Force setting X/Y, in case of dragging + */ + setXY (x, y, force) { + if (this.isStage) return; + if (this.dragging && !force) return; + const oldX = this.x; + const oldY = this.y; + if (this.renderer) { + const position = this.renderer.getFencedPositionOfDrawable(this.drawableID, [x, y]); + this.x = position[0]; + this.y = position[1]; + + this.renderer.updateDrawableProperties(this.drawableID, { + position: position + }); + if (this.visible) { + this.runtime.requestRedraw(); + } + } else { + this.x = x; + this.y = y; + } + this.emit(RenderedTarget.EVENT_TARGET_MOVED, this, oldX, oldY, force); + this.runtime.requestTargetsUpdate(this); + } + + /** + * Get the rendered direction and scale, after applying rotation style. + * @return {object} Direction and scale to render. + */ + _getRenderedDirectionAndScale () { + // Default: no changes to `this.direction` or `this.scale`. + let finalDirection = this.direction; + let finalScale = [this.size, this.size]; + if (this.rotationStyle === RenderedTarget.ROTATION_STYLE_NONE) { + // Force rendered direction to be 90. + finalDirection = 90; + } else if (this.rotationStyle === RenderedTarget.ROTATION_STYLE_LEFT_RIGHT) { + // Force rendered direction to be 90, and flip drawable if needed. + finalDirection = 90; + const scaleFlip = (this.direction < 0) ? -1 : 1; + finalScale = [scaleFlip * this.size, this.size]; + } + return {direction: finalDirection, scale: finalScale}; + } + + /** + * Set the direction. + * @param {!number} direction New direction. + */ + setDirection (direction) { + if (this.isStage) { + return; + } + if (!isFinite(direction)) { + return; + } + // Keep direction between -179 and +180. + this.direction = MathUtil.wrapClamp(direction, -179, 180); + if (this.renderer) { + const renderedDirectionScale = this._getRenderedDirectionAndScale(); + this.renderer.updateDrawableProperties(this.drawableID, { + direction: renderedDirectionScale.direction, + scale: renderedDirectionScale.scale + }); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Set draggability; i.e., whether it's able to be dragged in the player + * @param {!boolean} draggable True if should be draggable. + */ + setDraggable (draggable) { + if (this.isStage) return; + this.draggable = !!draggable; + this.runtime.requestTargetsUpdate(this); + } + + /** + * Set a say bubble. + * @param {?string} type Type of say bubble: "say", "think", or null. + * @param {?string} message Message to put in say bubble. + */ + setSay (type, message) { + if (this.isStage) { + return; + } + // @todo: Render to stage. + if (!type || !message) { + log.info('Clearing say bubble'); + return; + } + log.info('Setting say bubble:', type, message); + } + + /** + * Set visibility; i.e., whether it's shown or hidden. + * @param {!boolean} visible True if should be shown. + */ + setVisible (visible) { + if (this.isStage) { + return; + } + this.visible = !!visible; + if (this.renderer) { + this.renderer.updateDrawableProperties(this.drawableID, { + visible: this.visible + }); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Set size, as a percentage of the costume size. + * @param {!number} size Size of rendered target, as % of costume size. + */ + setSize (size) { + if (this.isStage) { + return; + } + if (this.renderer) { + // Clamp to scales relative to costume and stage size. + // See original ScratchSprite.as:setSize. + const costumeSize = this.renderer.getSkinSize(this.drawableID); + const origW = costumeSize[0]; + const origH = costumeSize[1]; + const minScale = Math.min(1, Math.max(5 / origW, 5 / origH)); + const maxScale = Math.min( + (1.5 * this.runtime.constructor.STAGE_WIDTH) / origW, + (1.5 * this.runtime.constructor.STAGE_HEIGHT) / origH + ); + this.size = MathUtil.clamp(size / 100, minScale, maxScale) * 100; + const renderedDirectionScale = this._getRenderedDirectionAndScale(); + this.renderer.updateDrawableProperties(this.drawableID, { + direction: renderedDirectionScale.direction, + scale: renderedDirectionScale.scale + }); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Set a particular graphic effect value. + * @param {!string} effectName Name of effect (see `RenderedTarget.prototype.effects`). + * @param {!number} value Numerical magnitude of effect. + */ + setEffect (effectName, value) { + if (!this.effects.hasOwnProperty(effectName)) return; + this.effects[effectName] = value; + if (this.renderer) { + const props = {}; + props[effectName] = this.effects[effectName]; + this.renderer.updateDrawableProperties(this.drawableID, props); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + } + + /** + * Clear all graphic effects on this rendered target. + */ + clearEffects () { + for (const effectName in this.effects) { + if (!this.effects.hasOwnProperty(effectName)) continue; + this.effects[effectName] = 0; + } + if (this.renderer) { + this.renderer.updateDrawableProperties(this.drawableID, this.effects); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + } + + /** + * Set the current costume. + * @param {number} index New index of costume. + */ + setCostume (index) { + // Keep the costume index within possible values. + index = Math.round(index); + this.currentCostume = MathUtil.wrapClamp( + index, 0, this.sprite.costumes.length - 1 + ); + if (this.renderer) { + const costume = this.sprite.costumes[this.currentCostume]; + const drawableProperties = { + skinId: costume.skinId, + costumeResolution: costume.bitmapResolution + }; + if ( + typeof costume.rotationCenterX !== 'undefined' && + typeof costume.rotationCenterY !== 'undefined' + ) { + const scale = costume.bitmapResolution || 1; + drawableProperties.rotationCenter = [ + costume.rotationCenterX / scale, + costume.rotationCenterY / scale + ]; + } + this.renderer.updateDrawableProperties(this.drawableID, drawableProperties); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Add a costume, taking care to avoid duplicate names. + * @param {!object} costumeObject Object representing the costume. + */ + addCostume (costumeObject) { + const usedNames = this.sprite.costumes.map(costume => costume.name); + costumeObject.name = StringUtil.unusedName(costumeObject.name, usedNames); + this.sprite.costumes.push(costumeObject); + } + + /** + * Rename a costume, taking care to avoid duplicate names. + * @param {int} costumeIndex - the index of the costume to be renamed. + * @param {string} newName - the desired new name of the costume (will be modified if already in use). + */ + renameCostume (costumeIndex, newName) { + const usedNames = this.sprite.costumes + .filter((costume, index) => costumeIndex !== index) + .map(costume => costume.name); + this.sprite.costumes[costumeIndex].name = StringUtil.unusedName(newName, usedNames); + } + + /** + * Delete a costume by index. + * @param {number} index Costume index to be deleted + */ + deleteCostume (index) { + const originalCostumeCount = this.sprite.costumes.length; + if (originalCostumeCount === 1) return; + + this.sprite.costumes = this.sprite.costumes + .slice(0, index) + .concat(this.sprite.costumes.slice(index + 1)); + + if (index === this.currentCostume && index === originalCostumeCount - 1) { + this.setCostume(index - 1); + } else if (index < this.currentCostume) { + this.setCostume(this.currentCostume - 1); + } else { + this.setCostume(this.currentCostume); + } + + this.runtime.requestTargetsUpdate(this); + } + + /** + * Add a sound, taking care to avoid duplicate names. + * @param {!object} soundObject Object representing the sound. + */ + addSound (soundObject) { + const usedNames = this.sprite.sounds.map(sound => sound.name); + soundObject.name = StringUtil.unusedName(soundObject.name, usedNames); + this.sprite.sounds.push(soundObject); + } + + /** + * Rename a sound, taking care to avoid duplicate names. + * @param {int} soundIndex - the index of the sound to be renamed. + * @param {string} newName - the desired new name of the sound (will be modified if already in use). + */ + renameSound (soundIndex, newName) { + const usedNames = this.sprite.sounds + .filter((sound, index) => soundIndex !== index) + .map(sound => sound.name); + this.sprite.sounds[soundIndex].name = StringUtil.unusedName(newName, usedNames); + } + + /** + * Delete a sound by index. + * @param {number} index Sound index to be deleted + */ + deleteSound (index) { + this.sprite.sounds = this.sprite.sounds + .slice(0, index) + .concat(this.sprite.sounds.slice(index + 1)); + this.runtime.requestTargetsUpdate(this); + } + + /** + * Update the rotation style. + * @param {!string} rotationStyle New rotation style. + */ + setRotationStyle (rotationStyle) { + if (rotationStyle === RenderedTarget.ROTATION_STYLE_NONE) { + this.rotationStyle = RenderedTarget.ROTATION_STYLE_NONE; + } else if (rotationStyle === RenderedTarget.ROTATION_STYLE_ALL_AROUND) { + this.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND; + } else if (rotationStyle === RenderedTarget.ROTATION_STYLE_LEFT_RIGHT) { + this.rotationStyle = RenderedTarget.ROTATION_STYLE_LEFT_RIGHT; + } + if (this.renderer) { + const renderedDirectionScale = this._getRenderedDirectionAndScale(); + this.renderer.updateDrawableProperties(this.drawableID, { + direction: renderedDirectionScale.direction, + scale: renderedDirectionScale.scale + }); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Get a costume index of this rendered target, by name of the costume. + * @param {?string} costumeName Name of a costume. + * @return {number} Index of the named costume, or -1 if not present. + */ + getCostumeIndexByName (costumeName) { + for (let i = 0; i < this.sprite.costumes.length; i++) { + if (this.sprite.costumes[i].name === costumeName) { + return i; + } + } + return -1; + } + + /** + * Get a costume of this rendered target by id. + * @return {object} current costume + */ + getCurrentCostume () { + return this.sprite.costumes[this.currentCostume]; + } + + /** + * Get full costume list + * @return {object[]} list of costumes + */ + getCostumes () { + return this.sprite.costumes; + } + + /** + * Get full sound list + * @return {object[]} list of sounds + */ + getSounds () { + return this.sprite.sounds; + } + + /** + * Update all drawable properties for this rendered target. + * Use when a batch has changed, e.g., when the drawable is first created. + */ + updateAllDrawableProperties () { + if (this.renderer) { + const renderedDirectionScale = this._getRenderedDirectionAndScale(); + const costume = this.sprite.costumes[this.currentCostume]; + const bitmapResolution = costume.bitmapResolution || 1; + const props = { + position: [this.x, this.y], + direction: renderedDirectionScale.direction, + draggable: this.draggable, + scale: renderedDirectionScale.scale, + visible: this.visible, + skinId: costume.skinId, + costumeResolution: bitmapResolution, + rotationCenter: [ + costume.rotationCenterX / bitmapResolution, + costume.rotationCenterY / bitmapResolution + ] + }; + for (const effectName in this.effects) { + if (!this.effects.hasOwnProperty(effectName)) continue; + props[effectName] = this.effects[effectName]; + } + this.renderer.updateDrawableProperties(this.drawableID, props); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + this.runtime.requestTargetsUpdate(this); + } + + /** + * Return the human-readable name for this rendered target, e.g., the sprite's name. + * @override + * @returns {string} Human-readable name. + */ + getName () { + return this.sprite.name; + } + + /** + * Return whether this rendered target is a sprite (not a clone, not the stage). + * @return {boolean} True if not a clone and not the stage. + */ + isSprite () { + return !this.isStage && this.isOriginal; + } + + /** + * Return the rendered target's tight bounding box. + * Includes top, left, bottom, right attributes in Scratch coordinates. + * @return {?object} Tight bounding box, or null. + */ + getBounds () { + if (this.renderer) { + return this.runtime.renderer.getBounds(this.drawableID); + } + return null; + } + + /** + * Return whether touching a point. + * @param {number} x X coordinate of test point. + * @param {number} y Y coordinate of test point. + * @return {boolean} True iff the rendered target is touching the point. + */ + isTouchingPoint (x, y) { + if (this.renderer) { + // @todo: Update once pick is in Scratch coordinates. + // Limits test to this Drawable, so this will return true + // even if the clone is obscured by another Drawable. + const pickResult = this.runtime.renderer.pick( + x, y, + null, null, + [this.drawableID] + ); + return pickResult === this.drawableID; + } + return false; + } + + /** + * Return whether touching a stage edge. + * @return {boolean} True iff the rendered target is touching the stage edge. + */ + isTouchingEdge () { + if (this.renderer) { + const stageWidth = this.runtime.constructor.STAGE_WIDTH; + const stageHeight = this.runtime.constructor.STAGE_HEIGHT; + const bounds = this.getBounds(); + if (bounds.left < -stageWidth / 2 || + bounds.right > stageWidth / 2 || + bounds.top > stageHeight / 2 || + bounds.bottom < -stageHeight / 2) { + return true; + } + } + return false; + } + + /** + * Return whether touching any of a named sprite's clones. + * @param {string} spriteName Name of the sprite. + * @return {boolean} True iff touching a clone of the sprite. + */ + isTouchingSprite (spriteName) { + const firstClone = this.runtime.getSpriteTargetByName(spriteName); + if (!firstClone || !this.renderer) { + return false; + } + const drawableCandidates = firstClone.sprite.clones.map(clone => clone.drawableID); + return this.renderer.isTouchingDrawables( + this.drawableID, drawableCandidates); + } + + /** + * Return whether touching a color. + * @param {Array.} rgb [r,g,b], values between 0-255. + * @return {Promise.} True iff the rendered target is touching the color. + */ + isTouchingColor (rgb) { + if (this.renderer) { + return this.renderer.isTouchingColor(this.drawableID, rgb); + } + return false; + } + + /** + * Return whether rendered target's color is touching a color. + * @param {object} targetRgb {Array.} [r,g,b], values between 0-255. + * @param {object} maskRgb {Array.} [r,g,b], values between 0-255. + * @return {Promise.} True iff the color is touching the color. + */ + colorIsTouchingColor (targetRgb, maskRgb) { + if (this.renderer) { + return this.renderer.isTouchingColor( + this.drawableID, + targetRgb, + maskRgb + ); + } + return false; + } + + /** + * Move to the front layer. + */ + goToFront () { + if (this.renderer) { + this.renderer.setDrawableOrder(this.drawableID, Infinity); + } + } + + /** + * Move to the back layer. + */ + goToBack () { + if (this.renderer) { + this.renderer.setDrawableOrder(this.drawableID, -Infinity, false, 1); + } + } + + /** + * Move forward a number of layers. + * @param {number} nLayers How many layers to go forward. + */ + goForwardLayers (nLayers) { + if (this.renderer) { + this.renderer.setDrawableOrder(this.drawableID, nLayers, true, 1); + } + } + + /** + * Move backward a number of layers. + * @param {number} nLayers How many layers to go backward. + */ + goBackwardLayers (nLayers) { + if (this.renderer) { + this.renderer.setDrawableOrder(this.drawableID, -nLayers, true, 1); + } + } + + /** + * Move behind some other rendered target. + * @param {!RenderedTarget} other Other rendered target to move behind. + */ + goBehindOther (other) { + if (this.renderer) { + const otherLayer = this.renderer.setDrawableOrder( + other.drawableID, 0, true); + this.renderer.setDrawableOrder(this.drawableID, otherLayer); + } + } + + /** + * Keep a desired position within a fence. + * @param {number} newX New desired X position. + * @param {number} newY New desired Y position. + * @param {object=} optFence Optional fence with left, right, top bottom. + * @return {Array.} Fenced X and Y coordinates. + */ + keepInFence (newX, newY, optFence) { + let fence = optFence; + if (!fence) { + fence = { + left: -this.runtime.constructor.STAGE_WIDTH / 2, + right: this.runtime.constructor.STAGE_WIDTH / 2, + top: this.runtime.constructor.STAGE_HEIGHT / 2, + bottom: -this.runtime.constructor.STAGE_HEIGHT / 2 + }; + } + const bounds = this.getBounds(); + if (!bounds) return; + // Adjust the known bounds to the target position. + bounds.left += (newX - this.x); + bounds.right += (newX - this.x); + bounds.top += (newY - this.y); + bounds.bottom += (newY - this.y); + // Find how far we need to move the target position. + let dx = 0; + let dy = 0; + if (bounds.left < fence.left) { + dx += fence.left - bounds.left; + } + if (bounds.right > fence.right) { + dx += fence.right - bounds.right; + } + if (bounds.top > fence.top) { + dy += fence.top - bounds.top; + } + if (bounds.bottom < fence.bottom) { + dy += fence.bottom - bounds.bottom; + } + return [newX + dx, newY + dy]; + } + + /** + * Make a clone, copying any run-time properties. + * If we've hit the global clone limit, returns null. + * @return {RenderedTarget} New clone. + */ + makeClone () { + if (!this.runtime.clonesAvailable() || this.isStage) { + return null; // Hit max clone limit, or this is the stage. + } + this.runtime.changeCloneCounter(1); + const newClone = this.sprite.createClone(); + // Copy all properties. + newClone.x = this.x; + newClone.y = this.y; + newClone.direction = this.direction; + newClone.draggable = this.draggable; + newClone.visible = this.visible; + newClone.size = this.size; + newClone.currentCostume = this.currentCostume; + newClone.rotationStyle = this.rotationStyle; + newClone.effects = JSON.parse(JSON.stringify(this.effects)); + newClone.variables = JSON.parse(JSON.stringify(this.variables)); + newClone.lists = JSON.parse(JSON.stringify(this.lists)); + newClone.initDrawable(); + newClone.updateAllDrawableProperties(); + // Place behind the current target. + newClone.goBehindOther(this); + return newClone; + } + + /** + * Make a duplicate using a duplicate sprite. + * @return {RenderedTarget} New clone. + */ + duplicate () { + return this.sprite.duplicate().then(newSprite => { + const newTarget = newSprite.createClone(); + // Copy all properties. + // @todo refactor with clone methods + newTarget.x = Math.random() * 400 / 2; + newTarget.y = Math.random() * 300 / 2; + newTarget.direction = this.direction; + newTarget.draggable = this.draggable; + newTarget.visible = this.visible; + newTarget.size = this.size; + newTarget.currentCostume = this.currentCostume; + newTarget.rotationStyle = this.rotationStyle; + newTarget.effects = JSON.parse(JSON.stringify(this.effects)); + newTarget.variables = JSON.parse(JSON.stringify(this.variables)); + newTarget.lists = JSON.parse(JSON.stringify(this.lists)); + newTarget.updateAllDrawableProperties(); + newTarget.goBehindOther(this); + return newTarget; + }); + } + + /** + * Called when the project receives a "green flag." + * For a rendered target, this clears graphic effects. + */ + onGreenFlag () { + this.clearEffects(); + } + + /** + * Called when the project receives a "stop all" + * Stop all sounds and clear graphic effects. + */ + onStopAll () { + this.clearEffects(); + if (this.audioPlayer) { + this.audioPlayer.stopAllSounds(); + this.audioPlayer.clearEffects(); + } + } + + /** + * Post/edit sprite info. + * @param {object} data An object with sprite info data to set. + */ + postSpriteInfo (data) { + const force = data.hasOwnProperty('force') ? data.force : null; + const isXChanged = data.hasOwnProperty('x'); + const isYChanged = data.hasOwnProperty('y'); + if (isXChanged || isYChanged) { + this.setXY(isXChanged ? data.x : this.x, isYChanged ? data.y : this.y, force); + } + if (data.hasOwnProperty('direction')) { + this.setDirection(data.direction); + } + if (data.hasOwnProperty('draggable')) { + this.setDraggable(data.draggable); + } + if (data.hasOwnProperty('rotationStyle')) { + this.setRotationStyle(data.rotationStyle); + } + if (data.hasOwnProperty('visible')) { + this.setVisible(data.visible); + } + if (data.hasOwnProperty('size')) { + this.setSize(data.size); + } + } + + /** + * Put the sprite into the drag state. While in effect, setXY must be forced + */ + startDrag () { + this.dragging = true; + } + + /** + * Remove the sprite from the drag state. + */ + stopDrag () { + this.dragging = false; + } + + + /** + * Serialize sprite info, used when emitting events about the sprite + * @returns {object} Sprite data as a simple object + */ + toJSON () { + const costumes = this.getCostumes(); + return { + id: this.id, + name: this.getName(), + isStage: this.isStage, + x: this.x, + y: this.y, + size: this.size, + direction: this.direction, + draggable: this.draggable, + currentCostume: this.currentCostume, + costume: costumes[this.currentCostume], + costumeCount: costumes.length, + visible: this.visible, + rotationStyle: this.rotationStyle, + blocks: this.blocks._blocks, + variables: this.variables, + lists: this.lists, + costumes: costumes, + sounds: this.getSounds() + }; + } + + /** + * Dispose, destroying any run-time properties. + */ + dispose () { + this.runtime.changeCloneCounter(-1); + this.runtime.stopForTarget(this); + this.sprite.removeClone(this); + if (this.renderer && this.drawableID !== null) { + this.renderer.destroyDrawable(this.drawableID); + if (this.visible) { + this.runtime.requestRedraw(); + } + } + if (this.audioPlayer) { + this.audioPlayer.stopAllSounds(); + this.audioPlayer.dispose(); + } + } +} + +module.exports = RenderedTarget; diff --git a/src/sprites/sprite.js b/src/sprites/sprite.js index 2afb25e5..bf68c98d 100644 --- a/src/sprites/sprite.js +++ b/src/sprites/sprite.js @@ -1,57 +1,110 @@ -var Clone = require('./clone'); -var Blocks = require('../engine/blocks'); +const RenderedTarget = require('./rendered-target'); +const Blocks = require('../engine/blocks'); +const {loadSoundFromAsset} = require('../import/load-sound'); +const {loadCostumeFromAsset} = require('../import/load-costume'); +const StringUtil = require('../util/string-util'); -/** - * Sprite to be used on the Scratch stage. - * All clones of a sprite have shared blocks, shared costumes, shared variables. - * @param {?Blocks} blocks Shared blocks object for all clones of sprite. - * @param {Runtime} runtime Reference to the runtime. - * @constructor - */ -function Sprite (blocks, runtime) { - this.runtime = runtime; - if (!blocks) { - // Shared set of blocks for all clones. - blocks = new Blocks(); +class Sprite { + /** + * Sprite to be used on the Scratch stage. + * All clones of a sprite have shared blocks, shared costumes, shared variables. + * @param {?Blocks} blocks Shared blocks object for all clones of sprite. + * @param {Runtime} runtime Reference to the runtime. + * @constructor + */ + constructor (blocks, runtime) { + this.runtime = runtime; + if (!blocks) { + // Shared set of blocks for all clones. + blocks = new Blocks(); + } + this.blocks = blocks; + /** + * Human-readable name for this sprite (and all clones). + * @type {string} + */ + this.name = ''; + /** + * List of costumes for this sprite. + * Each entry is an object, e.g., + * { + * skinId: 1, + * name: "Costume Name", + * bitmapResolution: 2, + * rotationCenterX: 0, + * rotationCenterY: 0 + * } + * @type {Array.} + */ + this.costumes = []; + /** + * List of sounds for this sprite. + */ + this.sounds = []; + /** + * List of clones for this sprite, including the original. + * @type {Array.} + */ + this.clones = []; } - this.blocks = blocks; + /** - * Human-readable name for this sprite (and all clones). - * @type {string} + * Create a clone of this sprite. + * @returns {!RenderedTarget} Newly created clone. */ - this.name = ''; + createClone () { + const newClone = new RenderedTarget(this, this.runtime); + newClone.isOriginal = this.clones.length === 0; + this.clones.push(newClone); + newClone.initAudio(); + if (newClone.isOriginal) { + newClone.initDrawable(); + this.runtime.fireTargetWasCreated(newClone); + } else { + this.runtime.fireTargetWasCreated(newClone, this.clones[0]); + } + return newClone; + } + /** - * List of costumes for this sprite. - * Each entry is an object, e.g., - * { - * skin: "costume.svg", - * name: "Costume Name", - * bitmapResolution: 2, - * rotationCenterX: 0, - * rotationCenterY: 0 - * } - * @type {Array.} + * Disconnect a clone from this sprite. The clone is unmodified. + * In particular, the clone's dispose() method is not called. + * @param {!RenderedTarget} clone - the clone to be removed. */ - this.costumes = []; - /** - * List of clones for this sprite, including the original. - * @type {Array.} - */ - this.clones = []; + removeClone (clone) { + this.runtime.fireTargetWasRemoved(clone); + const cloneIndex = this.clones.indexOf(clone); + if (cloneIndex >= 0) { + this.clones.splice(cloneIndex, 1); + } + } + + duplicate () { + const newSprite = new Sprite(null, this.runtime); + + newSprite.blocks = this.blocks.duplicate(); + + const allNames = this.runtime.targets.map(t => t.name); + newSprite.name = StringUtil.unusedName(this.name, allNames); + + const assetPromises = []; + + newSprite.costumes = this.costumes.map(costume => { + const newCostume = Object.assign({}, costume); + const costumeAsset = this.runtime.storage.get(costume.assetId); + assetPromises.push(loadCostumeFromAsset(newCostume, costumeAsset, this.runtime)); + return newCostume; + }); + + newSprite.sounds = this.sounds.map(sound => { + const newSound = Object.assign({}, sound); + const soundAsset = this.runtime.storage.get(sound.assetId); + assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime)); + return newSound; + }); + + return Promise.all(assetPromises).then(() => newSprite); + } } -/** - * Create a clone of this sprite. - * @returns {!Clone} Newly created clone. - */ -Sprite.prototype.createClone = function () { - var newClone = new Clone(this, this.runtime); - newClone.isOriginal = this.clones.length == 0; - this.clones.push(newClone); - if (newClone.isOriginal) { - newClone.initDrawable(); - } - return newClone; -}; - module.exports = Sprite; diff --git a/src/util/cast.js b/src/util/cast.js index dda55bf8..152ff433 100644 --- a/src/util/cast.js +++ b/src/util/cast.js @@ -1,6 +1,4 @@ -var Color = require('../util/color'); - -function Cast () {} +const Color = require('../util/color'); /** * @fileoverview @@ -13,151 +11,183 @@ function Cast () {} * Use when coercing a value before computation. */ -/** - * Scratch cast to number. - * Treats NaN as 0. - * In Scratch 2.0, this is captured by `interp.numArg.` - * @param {*} value Value to cast to number. - * @return {number} The Scratch-casted number value. - */ -Cast.toNumber = function (value) { - var n = Number(value); - if (isNaN(n)) { - // Scratch treats NaN as 0, when needed as a number. - // E.g., 0 + NaN -> 0. - return 0; - } - return n; -}; - -/** - * Scratch cast to boolean. - * In Scratch 2.0, this is captured by `interp.boolArg.` - * Treats some string values differently from JavaScript. - * @param {*} value Value to cast to boolean. - * @return {boolean} The Scratch-casted boolean value. - */ -Cast.toBoolean = function (value) { - // Already a boolean? - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - // These specific strings are treated as false in Scratch. - if ((value == '') || - (value == '0') || - (value.toLowerCase() == 'false')) { - return false; +class Cast { + /** + * Scratch cast to number. + * Treats NaN as 0. + * In Scratch 2.0, this is captured by `interp.numArg.` + * @param {*} value Value to cast to number. + * @return {number} The Scratch-casted number value. + */ + static toNumber (value) { + const n = Number(value); + if (isNaN(n)) { + // Scratch treats NaN as 0, when needed as a number. + // E.g., 0 + NaN -> 0. + return 0; } - // All other strings treated as true. - return true; + return n; } - // Coerce other values and numbers. - return Boolean(value); -}; -/** - * Scratch cast to string. - * @param {*} value Value to cast to string. - * @return {string} The Scratch-casted string value. - */ -Cast.toString = function (value) { - return String(value); -}; - -/** - * Cast any Scratch argument to an RGB color object to be used for the renderer. - * @param {*} value Value to convert to RGB color object. - * @return {Array.} [r,g,b], values between 0-255. - */ -Cast.toRgbColorList = function (value) { - var color; - if (typeof value == 'string' && value.substring(0, 1) == '#') { - color = Color.hexToRgb(value); - } else { - color = Color.decimalToRgb(Cast.toNumber(value)); - } - return [color.r, color.g, color.b]; -}; - -/** - * Compare two values, using Scratch cast, case-insensitive string compare, etc. - * In Scratch 2.0, this is captured by `interp.compare.` - * @param {*} v1 First value to compare. - * @param {*} v2 Second value to compare. - * @returns {Number} Negative number if v1 < v2; 0 if equal; positive otherwise. - */ -Cast.compare = function (v1, v2) { - var n1 = Number(v1); - var n2 = Number(v2); - if (isNaN(n1) || isNaN(n2)) { - // At least one argument can't be converted to a number. - // Scratch compares strings as case insensitive. - var s1 = String(v1).toLowerCase(); - var s2 = String(v2).toLowerCase(); - return s1.localeCompare(s2); - } else { - // Compare as numbers. - return n1 - n2; - } -}; - -/** - * Determine if a Scratch argument number represents a round integer. - * @param {*} val Value to check. - * @return {boolean} True if number looks like an integer. - */ -Cast.isInt = function (val) { - // Values that are already numbers. - if (typeof val === 'number') { - if (isNaN(val)) { // NaN is considered an integer. + /** + * Scratch cast to boolean. + * In Scratch 2.0, this is captured by `interp.boolArg.` + * Treats some string values differently from JavaScript. + * @param {*} value Value to cast to boolean. + * @return {boolean} The Scratch-casted boolean value. + */ + static toBoolean (value) { + // Already a boolean? + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + // These specific strings are treated as false in Scratch. + if ((value === '') || + (value === '0') || + (value.toLowerCase() === 'false')) { + return false; + } + // All other strings treated as true. return true; } - // True if it's "round" (e.g., 2.0 and 2). - return val == parseInt(val); - } else if (typeof val === 'boolean') { - // `True` and `false` always represent integer after Scratch cast. - return true; - } else if (typeof val === 'string') { - // If it contains a decimal point, don't consider it an int. - return val.indexOf('.') < 0; + // Coerce other values and numbers. + return Boolean(value); } - return false; -}; -Cast.LIST_INVALID = 'INVALID'; -Cast.LIST_ALL = 'ALL'; -/** - * Compute a 1-based index into a list, based on a Scratch argument. - * Two special cases may be returned: - * LIST_ALL: if the block is referring to all of the items in the list. - * LIST_INVALID: if the index was invalid in any way. - * @param {*} index Scratch arg, including 1-based numbers or special cases. - * @param {number} length Length of the list. - * @return {(number|string)} 1-based index for list, LIST_ALL, or LIST_INVALID. - */ -Cast.toListIndex = function (index, length) { - if (typeof index !== 'number') { - if (index == 'all') { - return Cast.LIST_ALL; + /** + * Scratch cast to string. + * @param {*} value Value to cast to string. + * @return {string} The Scratch-casted string value. + */ + static toString (value) { + return String(value); + } + + /** + * Cast any Scratch argument to an RGB color array to be used for the renderer. + * @param {*} value Value to convert to RGB color array. + * @return {Array.} [r,g,b], values between 0-255. + */ + static toRgbColorList (value) { + const color = Cast.toRgbColorObject(value); + return [color.r, color.g, color.b]; + } + + /** + * Cast any Scratch argument to an RGB color object to be used for the renderer. + * @param {*} value Value to convert to RGB color object. + * @return {RGBOject} [r,g,b], values between 0-255. + */ + static toRgbColorObject (value) { + let color; + if (typeof value === 'string' && value.substring(0, 1) === '#') { + color = Color.hexToRgb(value); + } else { + color = Color.decimalToRgb(Cast.toNumber(value)); } - if (index == 'last') { - if (length > 0) { - return length; + return color; + } + + /** + * Determine if a Scratch argument is a white space string (or null / empty). + * @param {*} val value to check. + * @return {boolean} True if the argument is all white spaces or null / empty. + */ + static isWhiteSpace (val) { + return val === null || (typeof val === 'string' && val.trim().length === 0); + } + + /** + * Compare two values, using Scratch cast, case-insensitive string compare, etc. + * In Scratch 2.0, this is captured by `interp.compare.` + * @param {*} v1 First value to compare. + * @param {*} v2 Second value to compare. + * @returns {number} Negative number if v1 < v2; 0 if equal; positive otherwise. + */ + static compare (v1, v2) { + let n1 = Number(v1); + let n2 = Number(v2); + if (n1 === 0 && Cast.isWhiteSpace(v1)) { + n1 = NaN; + } else if (n2 === 0 && Cast.isWhiteSpace(v2)) { + n2 = NaN; + } + if (isNaN(n1) || isNaN(n2)) { + // At least one argument can't be converted to a number. + // Scratch compares strings as case insensitive. + const s1 = String(v1).toLowerCase(); + const s2 = String(v2).toLowerCase(); + return s1.localeCompare(s2); + } + // Compare as numbers. + return n1 - n2; + + } + + /** + * Determine if a Scratch argument number represents a round integer. + * @param {*} val Value to check. + * @return {boolean} True if number looks like an integer. + */ + static isInt (val) { + // Values that are already numbers. + if (typeof val === 'number') { + if (isNaN(val)) { // NaN is considered an integer. + return true; } - return Cast.LIST_INVALID; - } else if (index == 'random' || index == 'any') { - if (length > 0) { - return 1 + Math.floor(Math.random() * length); + // True if it's "round" (e.g., 2.0 and 2). + return val === parseInt(val, 10); + } else if (typeof val === 'boolean') { + // `True` and `false` always represent integer after Scratch cast. + return true; + } else if (typeof val === 'string') { + // If it contains a decimal point, don't consider it an int. + return val.indexOf('.') < 0; + } + return false; + } + + static get LIST_INVALID () { + return 'INVALID'; + } + + static get LIST_ALL () { + return 'ALL'; + } + + /** + * Compute a 1-based index into a list, based on a Scratch argument. + * Two special cases may be returned: + * LIST_ALL: if the block is referring to all of the items in the list. + * LIST_INVALID: if the index was invalid in any way. + * @param {*} index Scratch arg, including 1-based numbers or special cases. + * @param {number} length Length of the list. + * @return {(number|string)} 1-based index for list, LIST_ALL, or LIST_INVALID. + */ + static toListIndex (index, length) { + if (typeof index !== 'number') { + if (index === 'all') { + return Cast.LIST_ALL; } + if (index === 'last') { + if (length > 0) { + return length; + } + return Cast.LIST_INVALID; + } else if (index === 'random' || index === 'any') { + if (length > 0) { + return 1 + Math.floor(Math.random() * length); + } + return Cast.LIST_INVALID; + } + } + index = Math.floor(Cast.toNumber(index)); + if (index < 1 || index > length) { return Cast.LIST_INVALID; } + return index; } - index = Math.floor(Cast.toNumber(index)); - if (index < 1 || index > length) { - return Cast.LIST_INVALID; - } - return index; -}; +} module.exports = Cast; diff --git a/src/util/clone.js b/src/util/clone.js new file mode 100644 index 00000000..8473e115 --- /dev/null +++ b/src/util/clone.js @@ -0,0 +1,17 @@ +/** + * Methods for cloning JavaScript objects. + * @type {object} + */ +class Clone { + /** + * Deep-clone a "simple" object: one which can be fully expressed with JSON. + * Non-JSON values, such as functions, will be stripped from the clone. + * @param {object} original - the object to be cloned. + * @returns {object} a deep clone of the original object. + */ + static simple (original) { + return JSON.parse(JSON.stringify(original)); + } +} + +module.exports = Clone; diff --git a/src/util/color.js b/src/util/color.js index 2635c1b6..0e1bd228 100644 --- a/src/util/color.js +++ b/src/util/color.js @@ -1,76 +1,204 @@ -function Color () {} +class Color { + /** + * @typedef {object} RGBObject - An object representing a color in RGB format. + * @property {number} r - the red component, in the range [0, 255]. + * @property {number} g - the green component, in the range [0, 255]. + * @property {number} b - the blue component, in the range [0, 255]. + */ -/** - * Convert a Scratch decimal color to a hex string, #RRGGBB. - * @param {number} decimal RGB color as a decimal. - * @return {string} RGB color as #RRGGBB hex string. - */ -Color.decimalToHex = function (decimal) { - if (decimal < 0) { - decimal += 0xFFFFFF + 1; + /** + * @typedef {object} HSVObject - An object representing a color in HSV format. + * @property {number} h - hue, in the range [0-359). + * @property {number} s - saturation, in the range [0,1]. + * @property {number} v - value, in the range [0,1]. + */ + + /** @type {RGBObject} */ + static get RGB_BLACK () { + return {r: 0, g: 0, b: 0}; } - var hex = Number(decimal).toString(16); - hex = '#' + '000000'.substr(0, 6 - hex.length) + hex; - return hex; -}; -/** - * Convert a Scratch decimal color to an RGB color object. - * @param {number} decimal RGB color as decimal. - * @returns {Object} {r: R, g: G, b: B}, values between 0-255 - */ -Color.decimalToRgb = function (decimal) { - var r = (decimal >> 16) & 0xFF; - var g = (decimal >> 8) & 0xFF; - var b = decimal & 0xFF; - return {r: r, g: g, b: b}; -}; + /** @type {RGBObject} */ + static get RGB_WHITE () { + return {r: 255, g: 255, b: 255}; + } -/** - * Convert a hex color (e.g., F00, #03F, #0033FF) to an RGB color object. - * CC-BY-SA Tim Down: - * https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb - * @param {!string} hex Hex representation of the color. - * @return {Object} {r: R, g: G, b: B}, 0-255, or null. - */ -Color.hexToRgb = function (hex) { - var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, function(m, r, g, b) { - return r + r + g + g + b + b; - }); - var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null; -}; + /** + * Convert a Scratch decimal color to a hex string, #RRGGBB. + * @param {number} decimal RGB color as a decimal. + * @return {string} RGB color as #RRGGBB hex string. + */ + static decimalToHex (decimal) { + if (decimal < 0) { + decimal += 0xFFFFFF + 1; + } + let hex = Number(decimal).toString(16); + hex = `#${'000000'.substr(0, 6 - hex.length)}${hex}`; + return hex; + } -/** - * Convert an RGB color object to a hex color. - * @param {Object} rgb {r: R, g: G, b: B}, values between 0-255. - * @return {!string} Hex representation of the color. - */ -Color.rgbToHex = function (rgb) { - return Color.decimalToHex(Color.rgbToDecimal(rgb)); -}; + /** + * Convert a Scratch decimal color to an RGB color object. + * @param {number} decimal RGB color as decimal. + * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + */ + static decimalToRgb (decimal) { + const a = (decimal >> 24) & 0xFF; + const r = (decimal >> 16) & 0xFF; + const g = (decimal >> 8) & 0xFF; + const b = decimal & 0xFF; + return {r: r, g: g, b: b, a: a > 0 ? a : 255}; + } -/** - * Convert an RGB color object to a Scratch decimal color. - * @param {Object} rgb {r: R, g: G, b: B}, values between 0-255. - * @return {!number} Number representing the color. - */ -Color.rgbToDecimal = function (rgb) { - return (rgb.r << 16) + (rgb.g << 8) + rgb.b; -}; + /** + * Convert a hex color (e.g., F00, #03F, #0033FF) to an RGB color object. + * CC-BY-SA Tim Down: + * https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb + * @param {!string} hex Hex representation of the color. + * @return {RGBObject} null on failure, or rgb: {r: red [0,255], g: green [0,255], b: blue [0,255]}. + */ + static hexToRgb (hex) { + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b); + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; + } -/** -* Convert a hex color (e.g., F00, #03F, #0033FF) to a decimal color number. -* @param {!string} hex Hex representation of the color. -* @return {!number} Number representing the color. -*/ -Color.hexToDecimal = function (hex) { - return Color.rgbToDecimal(Color.hexToRgb(hex)); -}; + /** + * Convert an RGB color object to a hex color. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + * @return {!string} Hex representation of the color. + */ + static rgbToHex (rgb) { + return Color.decimalToHex(Color.rgbToDecimal(rgb)); + } + + /** + * Convert an RGB color object to a Scratch decimal color. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + * @return {!number} Number representing the color. + */ + static rgbToDecimal (rgb) { + return (rgb.r << 16) + (rgb.g << 8) + rgb.b; + } + + /** + * Convert a hex color (e.g., F00, #03F, #0033FF) to a decimal color number. + * @param {!string} hex Hex representation of the color. + * @return {!number} Number representing the color. + */ + static hexToDecimal (hex) { + return Color.rgbToDecimal(Color.hexToRgb(hex)); + } + + /** + * Convert an HSV color to RGB format. + * @param {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} + * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + */ + static hsvToRgb (hsv) { + let h = hsv.h % 360; + if (h < 0) h += 360; + const s = Math.max(0, Math.min(hsv.s, 1)); + const v = Math.max(0, Math.min(hsv.v, 1)); + + const i = Math.floor(h / 60); + const f = (h / 60) - i; + const p = v * (1 - s); + const q = v * (1 - (s * f)); + const t = v * (1 - (s * (1 - f))); + + let r; + let g; + let b; + + switch (i) { + default: + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + case 5: + r = v; + g = p; + b = q; + break; + } + + return { + r: Math.floor(r * 255), + g: Math.floor(g * 255), + b: Math.floor(b * 255) + }; + } + + /** + * Convert an RGB color to HSV format. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + * @return {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} + */ + static rgbToHsv (rgb) { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const x = Math.min(Math.min(r, g), b); + const v = Math.max(Math.max(r, g), b); + + // For grays, hue will be arbitrarily reported as zero. Otherwise, calculate + let h = 0; + let s = 0; + if (x !== v) { + const f = (r === x) ? g - b : ((g === x) ? b - r : r - g); + const i = (r === x) ? 3 : ((g === x) ? 5 : 1); + h = ((i - (f / (v - x))) * 60) % 360; + s = (v - x) / v; + } + + return {h: h, s: s, v: v}; + } + + /** + * Linear interpolation between rgb0 and rgb1. + * @param {RGBObject} rgb0 - the color corresponding to fraction1 <= 0. + * @param {RGBObject} rgb1 - the color corresponding to fraction1 >= 1. + * @param {number} fraction1 - the interpolation parameter. If this is 0.5, for example, mix the two colors equally. + * @return {RGBObject} the interpolated color. + */ + static mixRgb (rgb0, rgb1, fraction1) { + if (fraction1 <= 0) return rgb0; + if (fraction1 >= 1) return rgb1; + const fraction0 = 1 - fraction1; + return { + r: (fraction0 * rgb0.r) + (fraction1 * rgb1.r), + g: (fraction0 * rgb0.g) + (fraction1 * rgb1.g), + b: (fraction0 * rgb0.b) + (fraction1 * rgb1.b) + }; + } +} module.exports = Color; diff --git a/src/util/log.js b/src/util/log.js new file mode 100644 index 00000000..cdc04520 --- /dev/null +++ b/src/util/log.js @@ -0,0 +1,4 @@ +const minilog = require('minilog'); +minilog.enable(); + +module.exports = minilog('vm'); diff --git a/src/util/math-util.js b/src/util/math-util.js index 14ebb450..542f1bd4 100644 --- a/src/util/math-util.js +++ b/src/util/math-util.js @@ -1,48 +1,68 @@ -function MathUtil () {} +class MathUtil { + /** + * Convert a value from degrees to radians. + * @param {!number} deg Value in degrees. + * @return {!number} Equivalent value in radians. + */ + static degToRad (deg) { + return deg * Math.PI / 180; + } -/** - * Convert a value from degrees to radians. - * @param {!number} deg Value in degrees. - * @return {!number} Equivalent value in radians. - */ -MathUtil.degToRad = function (deg) { - return deg * Math.PI / 180; -}; + /** + * Convert a value from radians to degrees. + * @param {!number} rad Value in radians. + * @return {!number} Equivalent value in degrees. + */ + static radToDeg (rad) { + return rad * 180 / Math.PI; + } -/** - * Convert a value from radians to degrees. - * @param {!number} rad Value in radians. - * @return {!number} Equivalent value in degrees. - */ -MathUtil.radToDeg = function (rad) { - return rad * 180 / Math.PI; -}; + /** + * Clamp a number between two limits. + * If n < min, return min. If n > max, return max. Else, return n. + * @param {!number} n Number to clamp. + * @param {!number} min Minimum limit. + * @param {!number} max Maximum limit. + * @return {!number} Value of n clamped to min and max. + */ + static clamp (n, min, max) { + return Math.min(Math.max(n, min), max); + } -/** - * Clamp a number between two limits. - * If n < min, return min. If n > max, return max. Else, return n. - * @param {!number} n Number to clamp. - * @param {!number} min Minimum limit. - * @param {!number} max Maximum limit. - * @return {!number} Value of n clamped to min and max. - */ -MathUtil.clamp = function (n, min, max) { - return Math.min(Math.max(n, min), max); -}; + /** + * Keep a number between two limits, wrapping "extra" into the range. + * e.g., wrapClamp(7, 1, 5) == 2 + * wrapClamp(0, 1, 5) == 5 + * wrapClamp(-11, -10, 6) == 6, etc. + * @param {!number} n Number to wrap. + * @param {!number} min Minimum limit. + * @param {!number} max Maximum limit. + * @return {!number} Value of n wrapped between min and max. + */ + static wrapClamp (n, min, max) { + const range = (max - min) + 1; + return n - (Math.floor((n - min) / range) * range); + } -/** - * Keep a number between two limits, wrapping "extra" into the range. - * e.g., wrapClamp(7, 1, 5) == 2 - * wrapClamp(0, 1, 5) == 5 - * wrapClamp(-11, -10, 6) == 6, etc. - * @param {!number} n Number to wrap. - * @param {!number} min Minimum limit. - * @param {!number} max Maximum limit. - * @return {!number} Value of n wrapped between min and max. - */ -MathUtil.wrapClamp = function (n, min, max) { - var range = (max - min) + 1; - return n - Math.floor((n - min) / range) * range; -}; + + /** + * Convert a value from tan function in degrees. + * @param {!number} angle in degrees + * @return {!number} Correct tan value + */ + static tan (angle) { + angle = angle % 360; + switch (angle) { + case -270: + case 90: + return Infinity; + case -90: + case 270: + return -Infinity; + default: + return parseFloat(Math.tan((Math.PI * angle) / 180).toFixed(10)); + } + } +} module.exports = MathUtil; diff --git a/src/util/string-util.js b/src/util/string-util.js new file mode 100644 index 00000000..b71054a0 --- /dev/null +++ b/src/util/string-util.js @@ -0,0 +1,41 @@ +class StringUtil { + static withoutTrailingDigits (s) { + let i = s.length - 1; + while ((i >= 0) && ('0123456789'.indexOf(s.charAt(i)) > -1)) i--; + return s.slice(0, i + 1); + } + + static unusedName (name, existingNames) { + if (existingNames.indexOf(name) < 0) return name; + name = StringUtil.withoutTrailingDigits(name); + let i = 2; + while (existingNames.indexOf(name + i) >= 0) i++; + return name + i; + } + + /** + * Split a string on the first occurrence of a split character. + * @param {string} text - the string to split. + * @param {string} separator - split the text on this character. + * @returns {[string, string]} - the two parts of the split string, or [text, null] if no split character found. + * @example + * // returns ['foo', 'tar.gz'] + * splitFirst('foo.tar.gz', '.'); + * @example + * // returns ['foo', null] + * splitFirst('foo', '.'); + * @example + * // returns ['foo', ''] + * splitFirst('foo.', '.'); + */ + static splitFirst (text, separator) { + const index = text.indexOf(separator); + if (index >= 0) { + return [text.substring(0, index), text.substring(index + 1)]; + } + return [text, null]; + + } +} + +module.exports = StringUtil; diff --git a/src/util/timer.js b/src/util/timer.js index 48dd223d..8ad026e7 100644 --- a/src/util/timer.js +++ b/src/util/timer.js @@ -12,59 +12,78 @@ * to do some measurement yourself. */ -/** - * @constructor - */ -function Timer () {} - -/** - * Used to store the start time of a timer action. - * Updated when calling `timer.start`. - */ -Timer.prototype.startTime = 0; - -/** - * Return the currently known absolute time, in ms precision. - * @returns {number} ms elapsed since 1 January 1970 00:00:00 UTC. - */ -Timer.prototype.time = function () { - if (Date.now) { - return Date.now(); - } else { - return new Date().getTime(); +class Timer { + constructor () { + /** + * Used to store the start time of a timer action. + * Updated when calling `timer.start`. + */ + this.startTime = 0; } -}; -/** - * Returns a time accurate relative to other times produced by this function. - * If possible, will use sub-millisecond precision. - * If not, will use millisecond precision. - * Not guaranteed to produce the same absolute values per-system. - * @returns {number} ms-scale accurate time relative to other relative times. - */ -Timer.prototype.relativeTime = function () { - if (typeof self !== 'undefined' && - self.performance && 'now' in self.performance) { - return self.performance.now(); - } else { - return this.time(); + /** + * Disable use of self.performance for now as it results in lower performance + * However, instancing it like below (caching the self.performance to a local variable) negates most of the issues. + * @type {boolean} + */ + static get USE_PERFORMANCE () { + return false; } -}; -/** - * Start a timer for measuring elapsed time, - * at the most accurate precision possible. - */ -Timer.prototype.start = function () { - this.startTime = this.relativeTime(); -}; + /** + * Legacy object to allow for us to call now to get the old style date time (for backwards compatibility) + * @deprecated This is only called via the nowObj.now() if no other means is possible... + */ + static get legacyDateCode () { + return { + now: function () { + return new Date().getTime(); + } + }; + } -/** - * Check time elapsed since `timer.start` was called. - * @returns {number} Time elapsed, in ms (possibly sub-ms precision). - */ -Timer.prototype.timeElapsed = function () { - return this.relativeTime() - this.startTime; -}; + /** + * Use this object to route all time functions through single access points. + */ + static get nowObj () { + if (Timer.USE_PERFORMANCE && typeof self !== 'undefined' && self.performance && 'now' in self.performance) { + return self.performance; + } else if (Date.now) { + return Date; + } + return Timer.legacyDateCode; + } + + /** + * Return the currently known absolute time, in ms precision. + * @returns {number} ms elapsed since 1 January 1970 00:00:00 UTC. + */ + time () { + return Timer.nowObj.now(); + } + + /** + * Returns a time accurate relative to other times produced by this function. + * If possible, will use sub-millisecond precision. + * If not, will use millisecond precision. + * Not guaranteed to produce the same absolute values per-system. + * @returns {number} ms-scale accurate time relative to other relative times. + */ + relativeTime () { + return Timer.nowObj.now(); + } + + /** + * Start a timer for measuring elapsed time, + * at the most accurate precision possible. + */ + start () { + this.startTime = Timer.nowObj.now(); + } + + timeElapsed () { + return Timer.nowObj.now() - this.startTime; + } +} module.exports = Timer; diff --git a/src/util/uid.js b/src/util/uid.js index 532f9e9b..fd4c41e8 100644 --- a/src/util/uid.js +++ b/src/util/uid.js @@ -8,7 +8,7 @@ * Removed $ due to issue 251. * @private */ -var soup_ = '!#%()*+,-./:;=?@[]^_`{|}~' + +const soup_ = '!#%()*+,-./:;=?@[]^_`{|}~' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; /** @@ -16,11 +16,11 @@ var soup_ = '!#%()*+,-./:;=?@[]^_`{|}~' + * 87 characters ^ 20 length > 128 bits (better than a UUID). * @return {string} A globally unique ID string. */ -var uid = function () { - var length = 20; - var soupLength = soup_.length; - var id = []; - for (var i = 0; i < length; i++) { +const uid = function () { + const length = 20; + const soupLength = soup_.length; + const id = []; + for (let i = 0; i < length; i++) { id[i] = soup_.charAt(Math.random() * soupLength); } return id.join(''); diff --git a/src/util/xml-escape.js b/src/util/xml-escape.js index 00ce5bff..66daf83b 100644 --- a/src/util/xml-escape.js +++ b/src/util/xml-escape.js @@ -6,8 +6,8 @@ * @param {!string} unsafe Unsafe string. * @return {string} XML-escaped string, for use within an XML tag. */ -var xmlEscape = function (unsafe) { - return unsafe.replace(/[<>&'"]/g, function (c) { +const xmlEscape = function (unsafe) { + return unsafe.replace(/[<>&'"]/g, c => { switch (c) { case '<': return '<'; case '>': return '>'; diff --git a/src/virtual-machine.js b/src/virtual-machine.js new file mode 100644 index 00000000..cab40908 --- /dev/null +++ b/src/virtual-machine.js @@ -0,0 +1,782 @@ +const TextEncoder = require('text-encoding').TextEncoder; +const EventEmitter = require('events'); + +const centralDispatch = require('./dispatch/central-dispatch'); +const ExtensionManager = require('./extension-support/extension-manager'); +const log = require('./util/log'); +const Runtime = require('./engine/runtime'); +const sb2 = require('./serialization/sb2'); +const sb3 = require('./serialization/sb3'); +const StringUtil = require('./util/string-util'); +const formatMessage = require('format-message'); +const Variable = require('./engine/variable'); + +const {loadCostume} = require('./import/load-costume.js'); +const {loadSound} = require('./import/load-sound.js'); + +const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; + +/** + * Handles connections between blocks, stage, and extensions. + * @constructor + */ +class VirtualMachine extends EventEmitter { + constructor () { + super(); + + /** + * VM runtime, to store blocks, I/O devices, sprites/targets, etc. + * @type {!Runtime} + */ + this.runtime = new Runtime(); + centralDispatch.setService('runtime', this.runtime).catch(e => { + log.error(`Failed to register runtime service: ${JSON.stringify(e)}`); + }); + + /** + * The "currently editing"/selected target ID for the VM. + * Block events from any Blockly workspace are routed to this target. + * @type {Target} + */ + this.editingTarget = null; + + /** + * The currently dragging target, for redirecting IO data. + * @type {Target} + */ + this._dragTarget = null; + + // Runtime emits are passed along as VM emits. + this.runtime.on(Runtime.SCRIPT_GLOW_ON, glowData => { + this.emit(Runtime.SCRIPT_GLOW_ON, glowData); + }); + this.runtime.on(Runtime.SCRIPT_GLOW_OFF, glowData => { + this.emit(Runtime.SCRIPT_GLOW_OFF, glowData); + }); + this.runtime.on(Runtime.BLOCK_GLOW_ON, glowData => { + this.emit(Runtime.BLOCK_GLOW_ON, glowData); + }); + this.runtime.on(Runtime.BLOCK_GLOW_OFF, glowData => { + this.emit(Runtime.BLOCK_GLOW_OFF, glowData); + }); + this.runtime.on(Runtime.PROJECT_RUN_START, () => { + this.emit(Runtime.PROJECT_RUN_START); + }); + this.runtime.on(Runtime.PROJECT_RUN_STOP, () => { + this.emit(Runtime.PROJECT_RUN_STOP); + }); + this.runtime.on(Runtime.VISUAL_REPORT, visualReport => { + this.emit(Runtime.VISUAL_REPORT, visualReport); + }); + this.runtime.on(Runtime.TARGETS_UPDATE, () => { + this.emitTargetsUpdate(); + }); + this.runtime.on(Runtime.MONITORS_UPDATE, monitorList => { + this.emit(Runtime.MONITORS_UPDATE, monitorList); + }); + this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => { + this.emit(Runtime.EXTENSION_ADDED, blocksInfo); + }); + this.runtime.on(Runtime.BLOCKSINFO_UPDATE, blocksInfo => { + this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo); + }); + + this.extensionManager = new ExtensionManager(this.runtime); + + this.blockListener = this.blockListener.bind(this); + this.flyoutBlockListener = this.flyoutBlockListener.bind(this); + this.monitorBlockListener = this.monitorBlockListener.bind(this); + this.variableListener = this.variableListener.bind(this); + } + + /** + * Start running the VM - do this before anything else. + */ + start () { + this.runtime.start(); + } + + /** + * "Green flag" handler - start all threads starting with a green flag. + */ + greenFlag () { + this.runtime.greenFlag(); + } + + /** + * Set whether the VM is in "turbo mode." + * When true, loops don't yield to redraw. + * @param {boolean} turboModeOn Whether turbo mode should be set. + */ + setTurboMode (turboModeOn) { + this.runtime.turboMode = !!turboModeOn; + } + + /** + * Set whether the VM is in 2.0 "compatibility mode." + * When true, ticks go at 2.0 speed (30 TPS). + * @param {boolean} compatibilityModeOn Whether compatibility mode is set. + */ + setCompatibilityMode (compatibilityModeOn) { + this.runtime.setCompatibilityMode(!!compatibilityModeOn); + } + + /** + * Stop all threads and running activities. + */ + stopAll () { + this.runtime.stopAll(); + } + + /** + * Clear out current running project data. + */ + clear () { + this.runtime.dispose(); + this.editingTarget = null; + this.emitTargetsUpdate(); + } + + /** + * Get data for playground. Data comes back in an emitted event. + */ + getPlaygroundData () { + const instance = this; + // Only send back thread data for the current editingTarget. + const threadData = this.runtime.threads.filter(thread => thread.target === instance.editingTarget); + // Remove the target key, since it's a circular reference. + const filteredThreadData = JSON.stringify(threadData, (key, value) => { + if (key === 'target') return; + return value; + }, 2); + this.emit('playgroundData', { + blocks: this.editingTarget.blocks, + threads: filteredThreadData + }); + } + + /** + * Post I/O data to the virtual devices. + * @param {?string} device Name of virtual I/O device. + * @param {object} data Any data object to post to the I/O device. + */ + postIOData (device, data) { + if (this.runtime.ioDevices[device]) { + this.runtime.ioDevices[device].postData(data); + } + } + + /** + * Load a project from a Scratch 2.0 JSON representation. + * @param {?string} json JSON string representing the project. + * @return {!Promise} Promise that resolves after targets are installed. + */ + loadProject (json) { + // @todo: Handle other formats, e.g., Scratch 1.4, Scratch 3.0. + return this.fromJSON(json); + } + + /** + * Load a project from the Scratch web site, by ID. + * @param {string} id - the ID of the project to download, as a string. + */ + downloadProjectId (id) { + const storage = this.runtime.storage; + if (!storage) { + log.error('No storage module present; cannot load project: ', id); + return; + } + const vm = this; + const promise = storage.load(storage.AssetType.Project, id); + promise.then(projectAsset => { + vm.loadProject(projectAsset.decodeText()); + }); + } + + /** + * @returns {string} Project in a Scratch 3.0 JSON representation. + */ + saveProjectSb3 () { + // @todo: Handle other formats, e.g., Scratch 1.4, Scratch 2.0. + return this.toJSON(); + } + + /** + * Export project as a Scratch 3.0 JSON representation. + * @return {string} Serialized state of the runtime. + */ + toJSON () { + return JSON.stringify(sb3.serialize(this.runtime)); + } + + /** + * Load a project from a Scratch JSON representation. + * @param {string} json JSON string representing a project. + * @returns {Promise} Promise that resolves after the project has loaded + */ + fromJSON (json) { + // Clear the current runtime + this.clear(); + + // Validate & parse + if (typeof json !== 'string' && typeof json !== 'object') { + log.error('Failed to parse project. Invalid type supplied to fromJSON.'); + return; + } + + // Attempt to parse JSON if string is supplied + if (typeof json === 'string') json = JSON.parse(json); + + // Establish version, deserialize, and load into runtime + // @todo Support Scratch 1.4 + // @todo This is an extremely naïve / dangerous way of determining version. + // See `scratch-parser` for a more sophisticated validation + // methodology that should be adapted for use here + let deserializer; + if ((typeof json.meta !== 'undefined') && (typeof json.meta.semver !== 'undefined')) { + deserializer = sb3; + } else { + deserializer = sb2; + } + + return deserializer.deserialize(json, this.runtime) + .then(({targets, extensions}) => + this.installTargets(targets, extensions, true)); + } + + /** + * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets. + * @param {Array.} targets - the targets to be installed + * @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets + * @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite. + * @returns {Promise} resolved once targets have been installed + */ + installTargets (targets, extensions, wholeProject) { + const extensionPromises = []; + extensions.extensionIDs.forEach(extensionID => { + if (!this.extensionManager.isExtensionLoaded(extensionID)) { + const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; + extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL)); + } + }); + + targets = targets.filter(target => !!target); + + return Promise.all(extensionPromises).then(() => { + if (wholeProject) { + this.clear(); + } + targets.forEach(target => { + this.runtime.targets.push(target); + (/** @type RenderedTarget */ target).updateAllDrawableProperties(); + // Ensure unique sprite name + if (target.isSprite()) this.renameSprite(target.id, target.getName()); + }); + // Select the first target for editing, e.g., the first sprite. + if (wholeProject && (targets.length > 1)) { + this.editingTarget = targets[1]; + } else { + this.editingTarget = targets[0]; + } + + // Update the VM user's knowledge of targets and blocks on the workspace. + this.emitTargetsUpdate(); + this.emitWorkspaceUpdate(); + this.runtime.setEditingTarget(this.editingTarget); + }); + } + + /** + * Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format. + * @param {string} json JSON string representing the sprite. + * @returns {Promise} Promise that resolves after the sprite is added + */ + addSprite2 (json) { + // Validate & parse + if (typeof json !== 'string') { + log.error('Failed to parse sprite. Non-string supplied to addSprite2.'); + return; + } + json = JSON.parse(json); + if (typeof json !== 'object') { + log.error('Failed to parse sprite. JSON supplied to addSprite2 is not an object.'); + return; + } + + return sb2.deserialize(json, this.runtime, true) + .then(({targets, extensions}) => + this.installTargets(targets, extensions, false)); + } + + /** + * Add a costume to the current editing target. + * @param {string} md5ext - the MD5 and extension of the costume to be loaded. + * @param {!object} costumeObject Object representing the costume. + * @property {int} skinId - the ID of the costume's render skin, once installed. + * @property {number} rotationCenterX - the X component of the costume's origin. + * @property {number} rotationCenterY - the Y component of the costume's origin. + * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. + * @returns {?Promise} - a promise that resolves when the costume has been added + */ + addCostume (md5ext, costumeObject) { + return loadCostume(md5ext, costumeObject, this.runtime).then(() => { + this.editingTarget.addCostume(costumeObject); + this.editingTarget.setCostume( + this.editingTarget.sprite.costumes.length - 1 + ); + }); + } + + /** + * Rename a costume on the current editing target. + * @param {int} costumeIndex - the index of the costume to be renamed. + * @param {string} newName - the desired new name of the costume (will be modified if already in use). + */ + renameCostume (costumeIndex, newName) { + this.editingTarget.renameCostume(costumeIndex, newName); + this.emitTargetsUpdate(); + } + + /** + * Delete a costume from the current editing target. + * @param {int} costumeIndex - the index of the costume to be removed. + */ + deleteCostume (costumeIndex) { + this.editingTarget.deleteCostume(costumeIndex); + } + + /** + * Add a sound to the current editing target. + * @param {!object} soundObject Object representing the costume. + * @returns {?Promise} - a promise that resolves when the sound has been decoded and added + */ + addSound (soundObject) { + return loadSound(soundObject, this.runtime).then(() => { + this.editingTarget.addSound(soundObject); + this.emitTargetsUpdate(); + }); + } + + /** + * Rename a sound on the current editing target. + * @param {int} soundIndex - the index of the sound to be renamed. + * @param {string} newName - the desired new name of the sound (will be modified if already in use). + */ + renameSound (soundIndex, newName) { + this.editingTarget.renameSound(soundIndex, newName); + this.emitTargetsUpdate(); + } + + /** + * Get a sound buffer from the audio engine. + * @param {int} soundIndex - the index of the sound to be got. + * @return {AudioBuffer} the sound's audio buffer. + */ + getSoundBuffer (soundIndex) { + const id = this.editingTarget.sprite.sounds[soundIndex].soundId; + if (id && this.runtime && this.runtime.audioEngine) { + return this.runtime.audioEngine.getSoundBuffer(id); + } + return null; + } + + /** + * Update a sound buffer. + * @param {int} soundIndex - the index of the sound to be updated. + * @param {AudioBuffer} newBuffer - new audio buffer for the audio engine. + */ + updateSoundBuffer (soundIndex, newBuffer) { + const id = this.editingTarget.sprite.sounds[soundIndex].soundId; + if (id && this.runtime && this.runtime.audioEngine) { + this.runtime.audioEngine.updateSoundBuffer(id, newBuffer); + } + this.emitTargetsUpdate(); + } + + /** + * Delete a sound from the current editing target. + * @param {int} soundIndex - the index of the sound to be removed. + */ + deleteSound (soundIndex) { + this.editingTarget.deleteSound(soundIndex); + } + + /** + * Get an SVG string from storage. + * @param {int} costumeIndex - the index of the costume to be got. + * @return {string} the costume's SVG string, or null if it's not an SVG costume. + */ + getCostumeSvg (costumeIndex) { + const id = this.editingTarget.sprite.costumes[costumeIndex].assetId; + if (id && this.runtime && this.runtime.storage && + this.runtime.storage.get(id).dataFormat === 'svg') { + return this.runtime.storage.get(id).decodeText(); + } + return null; + } + + /** + * Update a costume with the given SVG + * @param {int} costumeIndex - the index of the costume to be updated. + * @param {string} svg - new SVG for the renderer. + * @param {number} rotationCenterX x of point about which the costume rotates, relative to its upper left corner + * @param {number} rotationCenterY y of point about which the costume rotates, relative to its upper left corner + */ + updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) { + const costume = this.editingTarget.sprite.costumes[costumeIndex]; + if (costume && this.runtime && this.runtime.renderer) { + costume.rotationCenterX = rotationCenterX; + costume.rotationCenterY = rotationCenterY; + this.runtime.renderer.updateSVGSkin(costume.skinId, svg, [rotationCenterX, rotationCenterY]); + } + const storage = this.runtime.storage; + costume.assetId = storage.builtinHelper.cache( + storage.AssetType.ImageVector, + storage.DataFormat.SVG, + (new TextEncoder()).encode(svg) + ); + this.emitTargetsUpdate(); + } + + /** + * Add a backdrop to the stage. + * @param {string} md5ext - the MD5 and extension of the backdrop to be loaded. + * @param {!object} backdropObject Object representing the backdrop. + * @property {int} skinId - the ID of the backdrop's render skin, once installed. + * @property {number} rotationCenterX - the X component of the backdrop's origin. + * @property {number} rotationCenterY - the Y component of the backdrop's origin. + * @property {number} [bitmapResolution] - the resolution scale for a bitmap backdrop. + * @returns {?Promise} - a promise that resolves when the backdrop has been added + */ + addBackdrop (md5ext, backdropObject) { + return loadCostume(md5ext, backdropObject, this.runtime).then(() => { + const stage = this.runtime.getTargetForStage(); + stage.addCostume(backdropObject); + stage.setCostume(stage.sprite.costumes.length - 1); + }); + } + + /** + * Rename a sprite. + * @param {string} targetId ID of a target whose sprite to rename. + * @param {string} newName New name of the sprite. + */ + renameSprite (targetId, newName) { + const target = this.runtime.getTargetById(targetId); + if (target) { + if (!target.isSprite()) { + throw new Error('Cannot rename non-sprite targets.'); + } + const sprite = target.sprite; + if (!sprite) { + throw new Error('No sprite associated with this target.'); + } + if (newName && RESERVED_NAMES.indexOf(newName) === -1) { + const names = this.runtime.targets + .filter(runtimeTarget => runtimeTarget.isSprite() && runtimeTarget.id !== target.id) + .map(runtimeTarget => runtimeTarget.sprite.name); + sprite.name = StringUtil.unusedName(newName, names); + } + this.emitTargetsUpdate(); + } else { + throw new Error('No target with the provided id.'); + } + } + + /** + * Delete a sprite and all its clones. + * @param {string} targetId ID of a target whose sprite to delete. + */ + deleteSprite (targetId) { + const target = this.runtime.getTargetById(targetId); + + if (target) { + const targetIndexBeforeDelete = this.runtime.targets.map(t => t.id).indexOf(target.id); + if (!target.isSprite()) { + throw new Error('Cannot delete non-sprite targets.'); + } + const sprite = target.sprite; + if (!sprite) { + throw new Error('No sprite associated with this target.'); + } + this.runtime.requestRemoveMonitorByTargetId(targetId); + const currentEditingTarget = this.editingTarget; + for (let i = 0; i < sprite.clones.length; i++) { + const clone = sprite.clones[i]; + this.runtime.stopForTarget(sprite.clones[i]); + this.runtime.disposeTarget(sprite.clones[i]); + // Ensure editing target is switched if we are deleting it. + if (clone === currentEditingTarget) { + const nextTargetIndex = Math.min(this.runtime.targets.length - 1, targetIndexBeforeDelete); + if (this.runtime.targets.length > 0){ + this.setEditingTarget(this.runtime.targets[nextTargetIndex].id); + } else { + this.editingTarget = null; + } + } + } + // Sprite object should be deleted by GC. + this.emitTargetsUpdate(); + } else { + throw new Error('No target with the provided id.'); + } + } + + /** + * Duplicate a sprite. + * @param {string} targetId ID of a target whose sprite to duplicate. + * @returns {Promise} Promise that resolves when duplicated target has + * been added to the runtime. + */ + duplicateSprite (targetId) { + const target = this.runtime.getTargetById(targetId); + if (!target) { + throw new Error('No target with the provided id.'); + } else if (!target.isSprite()) { + throw new Error('Cannot duplicate non-sprite targets.'); + } else if (!target.sprite) { + throw new Error('No sprite associated with this target.'); + } + return target.duplicate().then(newTarget => { + this.runtime.targets.push(newTarget); + this.setEditingTarget(newTarget.id); + }); + } + + /** + * Set the audio engine for the VM/runtime + * @param {!AudioEngine} audioEngine The audio engine to attach + */ + attachAudioEngine (audioEngine) { + this.runtime.attachAudioEngine(audioEngine); + } + + /** + * Set the renderer for the VM/runtime + * @param {!RenderWebGL} renderer The renderer to attach + */ + attachRenderer (renderer) { + this.runtime.attachRenderer(renderer); + } + + /** + * Set the storage module for the VM/runtime + * @param {!ScratchStorage} storage The storage module to attach + */ + attachStorage (storage) { + this.runtime.attachStorage(storage); + } + + /** + * set the current locale and builtin messages for the VM + * @param {[type]} locale current locale + * @param {[type]} messages builtin messages map for current locale + */ + setLocale (locale, messages) { + if (locale !== formatMessage.setup().locale) { + formatMessage.setup({locale: locale, translations: {[locale]: messages}}); + this.extensionManager.refreshBlocks(); + } + } + + /** + * Handle a Blockly event for the current editing target. + * @param {!Blockly.Event} e Any Blockly event. + */ + blockListener (e) { + if (this.editingTarget) { + this.editingTarget.blocks.blocklyListen(e, this.runtime); + } + } + + /** + * Handle a Blockly event for the flyout. + * @param {!Blockly.Event} e Any Blockly event. + */ + flyoutBlockListener (e) { + this.runtime.flyoutBlocks.blocklyListen(e, this.runtime); + } + + /** + * Handle a Blockly event for the flyout to be passed to the monitor container. + * @param {!Blockly.Event} e Any Blockly event. + */ + monitorBlockListener (e) { + // Filter events by type, since monitor blocks only need to listen to these events. + // Monitor blocks shouldn't be destroyed when flyout blocks are deleted. + if (['create', 'change'].indexOf(e.type) !== -1) { + this.runtime.monitorBlocks.blocklyListen(e, this.runtime); + } + } + + /** + * Handle a Blockly event for the variable map. + * @param {!Blockly.Event} e Any Blockly event. + */ + variableListener (e) { + // Filter events by type, since blocks only needs to listen to these + // var events. + if (['var_create', 'var_rename', 'var_delete'].indexOf(e.type) !== -1) { + this.runtime.getTargetForStage().blocks.blocklyListen(e, + this.runtime); + } + } + + /** + * Set an editing target. An editor UI can use this function to switch + * between editing different targets, sprites, etc. + * After switching the editing target, the VM may emit updates + * to the list of targets and any attached workspace blocks + * (see `emitTargetsUpdate` and `emitWorkspaceUpdate`). + * @param {string} targetId Id of target to set as editing. + */ + setEditingTarget (targetId) { + // Has the target id changed? If not, exit. + if (this.editingTarget && targetId === this.editingTarget.id) { + return; + } + const target = this.runtime.getTargetById(targetId); + if (target) { + this.editingTarget = target; + // Emit appropriate UI updates. + this.emitTargetsUpdate(); + this.emitWorkspaceUpdate(); + this.runtime.setEditingTarget(target); + } + } + + /** + * Repopulate the workspace with the blocks of the current editingTarget. This + * allows us to get around bugs like gui#413. + */ + refreshWorkspace () { + if (this.editingTarget) { + this.emitWorkspaceUpdate(); + this.runtime.setEditingTarget(this.editingTarget); + } + } + + /** + * Emit metadata about available targets. + * An editor UI could use this to display a list of targets and show + * the currently editing one. + */ + emitTargetsUpdate () { + this.emit('targetsUpdate', { + // [[target id, human readable target name], ...]. + targetList: this.runtime.targets + .filter( + // Don't report clones. + target => !target.hasOwnProperty('isOriginal') || target.isOriginal + ).map( + target => target.toJSON() + ), + // Currently editing target id. + editingTarget: this.editingTarget ? this.editingTarget.id : null + }); + } + + /** + * Emit an Blockly/scratch-blocks compatible XML representation + * of the current editing target's blocks. + */ + emitWorkspaceUpdate () { + // Create a list of broadcast message Ids according to the stage variables + const stageVariables = this.runtime.getTargetForStage().variables; + let messageIds = []; + for (const varId in stageVariables) { + if (stageVariables[varId].type === Variable.BROADCAST_MESSAGE_TYPE) { + messageIds.push(varId); + } + } + // Go through all blocks on all targets, removing referenced + // broadcast ids from the list. + for (let i = 0; i < this.runtime.targets.length; i++) { + const currTarget = this.runtime.targets[i]; + const currBlocks = currTarget.blocks._blocks; + for (const blockId in currBlocks) { + if (currBlocks[blockId].fields.BROADCAST_OPTION) { + const id = currBlocks[blockId].fields.BROADCAST_OPTION.id; + const index = messageIds.indexOf(id); + if (index !== -1) { + messageIds = messageIds.slice(0, index) + .concat(messageIds.slice(index + 1)); + } + } + } + } + // Anything left in messageIds is not referenced by a block, so delete it. + for (let i = 0; i < messageIds.length; i++) { + const id = messageIds[i]; + delete this.runtime.getTargetForStage().variables[id]; + } + const variableMap = Object.assign({}, + this.runtime.getTargetForStage().variables, + this.editingTarget.variables + ); + + const variables = Object.keys(variableMap).map(k => variableMap[k]); + + const xmlString = ` + + ${variables.map(v => v.toXML()).join()} + + ${this.editingTarget.blocks.toXML()} + `; + + this.emit('workspaceUpdate', {xml: xmlString}); + } + + /** + * Get a target id for a drawable id. Useful for interacting with the renderer + * @param {int} drawableId The drawable id to request the target id for + * @returns {?string} The target id, if found. Will also be null if the target found is the stage. + */ + getTargetIdForDrawableId (drawableId) { + const target = this.runtime.getTargetByDrawableId(drawableId); + if (target && target.hasOwnProperty('id') && target.hasOwnProperty('isStage') && !target.isStage) { + return target.id; + } + return null; + } + + /** + * Put a target into a "drag" state, during which its X/Y positions will be unaffected + * by blocks. + * @param {string} targetId The id for the target to put into a drag state + */ + startDrag (targetId) { + const target = this.runtime.getTargetById(targetId); + if (target) { + this._dragTarget = target; + target.startDrag(); + } + } + + /** + * Remove a target from a drag state, so blocks may begin affecting X/Y position again + * @param {string} targetId The id for the target to remove from the drag state + */ + stopDrag (targetId) { + const target = this.runtime.getTargetById(targetId); + if (target) { + this._dragTarget = null; + target.stopDrag(); + this.setEditingTarget(target.id); + } + } + + /** + * Post/edit sprite info for the current editing target or the drag target. + * @param {object} data An object with sprite info data to set. + */ + postSpriteInfo (data) { + if (this._dragTarget) { + this._dragTarget.postSpriteInfo(data); + } else { + this.editingTarget.postSpriteInfo(data); + } + } +} + +module.exports = VirtualMachine; diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 00000000..66619409 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + 'no-undefined': [0] + } +}; diff --git a/test/fixtures/clone-cleanup.sb2 b/test/fixtures/clone-cleanup.sb2 new file mode 100644 index 00000000..61baeb7f Binary files /dev/null and b/test/fixtures/clone-cleanup.sb2 differ diff --git a/test/fixtures/complex.sb2 b/test/fixtures/complex.sb2 new file mode 100644 index 00000000..6da5cdb1 Binary files /dev/null and b/test/fixtures/complex.sb2 differ diff --git a/test/fixtures/control.sb2 b/test/fixtures/control.sb2 new file mode 100644 index 00000000..ea758168 Binary files /dev/null and b/test/fixtures/control.sb2 differ diff --git a/test/fixtures/data.sb2 b/test/fixtures/data.sb2 new file mode 100644 index 00000000..e4b8c0b1 Binary files /dev/null and b/test/fixtures/data.sb2 differ diff --git a/test/fixtures/default.json b/test/fixtures/default.json deleted file mode 100644 index af478a4e..00000000 --- a/test/fixtures/default.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "objName": "Stage", - "sounds": [{ - "soundName": "pop", - "soundID": -1, - "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", - "sampleCount": 258, - "rate": 11025, - "format": "" - }], - "costumes": [{ - "costumeName": "backdrop1", - "baseLayerID": -1, - "baseLayerMD5": "739b5e2a2435f6e1ec2993791b423146.png", - "bitmapResolution": 1, - "rotationCenterX": 240, - "rotationCenterY": 180 - }], - "currentCostumeIndex": 0, - "penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png", - "penLayerID": -1, - "tempoBPM": 60, - "videoAlpha": 0.5, - "children": [{ - "objName": "Sprite1", - "sounds": [{ - "soundName": "meow", - "soundID": -1, - "md5": "83c36d806dc92327b9e7049a565c6bff.wav", - "sampleCount": 18688, - "rate": 22050, - "format": "" - }], - "costumes": [{ - "costumeName": "costume1", - "baseLayerID": -1, - "baseLayerMD5": "09dc888b0b7df19f70d81588ae73420e.svg", - "bitmapResolution": 1, - "rotationCenterX": 47, - "rotationCenterY": 55 - }, - { - "costumeName": "costume2", - "baseLayerID": -1, - "baseLayerMD5": "3696356a03a8d938318876a593572843.svg", - "bitmapResolution": 1, - "rotationCenterX": 47, - "rotationCenterY": 55 - }], - "currentCostumeIndex": 0, - "scratchX": 0, - "scratchY": 0, - "scale": 1, - "direction": 90, - "rotationStyle": "normal", - "isDraggable": false, - "indexInLibrary": 1, - "visible": true, - "spriteInfo": { - } - }], - "info": { - "videoOn": false, - "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/50.0.2661.102 Safari\/537.36", - "swfVersion": "v446", - "scriptCount": 0, - "spriteCount": 1, - "hasCloudData": false, - "flashVersion": "MAC 21,0,0,242" - } -} diff --git a/test/fixtures/default.sb2 b/test/fixtures/default.sb2 new file mode 100644 index 00000000..1736d2dd Binary files /dev/null and b/test/fixtures/default.sb2 differ diff --git a/test/fixtures/demo.json b/test/fixtures/demo.json index b10376ec..1467a463 100644 --- a/test/fixtures/demo.json +++ b/test/fixtures/demo.json @@ -1,359 +1 @@ -{ - "objName": "Stage", - "variables": [{ - "name": "x", - "value": "1", - "isPersistent": false - }, - { - "name": "y", - "value": "1", - "isPersistent": false - }, - { - "name": "z", - "value": "1", - "isPersistent": false - }, - { - "name": "d", - "value": "1", - "isPersistent": false - }, - { - "name": "a", - "value": 4, - "isPersistent": false - }], - "lists": [{ - "listName": "D# Minor Pentatonic", - "contents": ["78", - "75", - "73", - "75", - "70", - "78", - "73", - "75", - "75", - "78", - "75", - "73", - "75", - "70", - "75", - "78", - "73", - "75", - "78", - "75", - "73", - "75", - "70", - "73", - "68", - "70", - "66", - "68", - "63"], - "isPersistent": false, - "x": 5, - "y": 32, - "width": 125, - "height": 206, - "visible": true - }], - "scripts": [[52, - 8, - [["whenIReceive", "start"], - ["setVar:to:", "a", "1"], - ["doRepeat", - ["lineCountOfList:", "D# Minor Pentatonic"], - [["noteOn:duration:elapsed:from:", ["getLine:ofList:", ["readVariable", "a"], "D# Minor Pentatonic"], 0.5], ["changeVar:by:", "a", 1]]]]], - [53, - 186, - [["whenIReceive", "start"], - ["setVar:to:", "x", "1"], - ["rest:elapsed:from:", 7.25], - ["doRepeat", - ["lineCountOfList:", "D# Minor Pentatonic"], - [["noteOn:duration:elapsed:from:", ["getLine:ofList:", ["readVariable", "x"], "D# Minor Pentatonic"], 0.25], ["changeVar:by:", "x", 1]]]]], - [48, - 557, - [["whenIReceive", "start"], - ["setVar:to:", "z", "1"], - ["rest:elapsed:from:", 13], - ["doRepeat", - ["lineCountOfList:", "D# Minor Pentatonic"], - [["noteOn:duration:elapsed:from:", ["getLine:ofList:", ["readVariable", "z"], "D# Minor Pentatonic"], 0.0625], ["changeVar:by:", "z", 1]]]]], - [49, - 368, - [["whenIReceive", "start"], - ["setVar:to:", "y", "1"], - ["rest:elapsed:from:", 11], - ["doRepeat", - ["lineCountOfList:", "D# Minor Pentatonic"], - [["noteOn:duration:elapsed:from:", ["getLine:ofList:", ["readVariable", "y"], "D# Minor Pentatonic"], 0.125], ["changeVar:by:", "y", 1]]]]], - [52, - 745, - [["whenIReceive", "start"], - ["setVar:to:", "d", "1"], - ["rest:elapsed:from:", 13.5], - ["doRepeat", - ["lineCountOfList:", "D# Minor Pentatonic"], - [["noteOn:duration:elapsed:from:", ["getLine:ofList:", ["readVariable", "d"], "D# Minor Pentatonic"], 0.03125], ["changeVar:by:", "d", 1]]]]]], - "sounds": [{ - "soundName": "pop", - "soundID": 0, - "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", - "sampleCount": 258, - "rate": 11025, - "format": "" - }], - "costumes": [{ - "costumeName": "backdrop1", - "baseLayerID": 4, - "baseLayerMD5": "b61b1077b0ea1931abee9dbbfa7903ff.png", - "bitmapResolution": 2, - "rotationCenterX": 480, - "rotationCenterY": 360 - }], - "currentCostumeIndex": 0, - "penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png", - "penLayerID": 0, - "tempoBPM": 60, - "videoAlpha": 0.5, - "children": [{ - "objName": "Indicator", - "scripts": [[247.85, - 32.8, - [["procDef", "foo %n", ["bar"], [1], false], - ["hide"], - ["clearPenTrails"], - ["penColor:", 5968094], - ["say:", ["getParam", "bar", "r"]], - ["stopScripts", "this script"]]], - [41, 36, [["whenGreenFlag"], ["call", "foo %n", 1]]]], - "sounds": [{ - "soundName": "pop", - "soundID": 0, - "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", - "sampleCount": 258, - "rate": 11025, - "format": "" - }], - "costumes": [{ - "costumeName": "costume1", - "baseLayerID": 1, - "baseLayerMD5": "d36f6603ec293d2c2198d3ea05109fe0.png", - "bitmapResolution": 2, - "rotationCenterX": 0, - "rotationCenterY": 0 - }], - "currentCostumeIndex": 0, - "scratchX": 22, - "scratchY": -26, - "scale": 1, - "direction": 90, - "rotationStyle": "normal", - "isDraggable": false, - "indexInLibrary": 3, - "visible": false, - "spriteInfo": { - } - }, - { - "target": "Stage", - "cmd": "timer", - "param": null, - "color": 2926050, - "label": "timer", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 5, - "y": 5, - "visible": false - }, - { - "target": "Stage", - "cmd": "getVar:", - "param": "x", - "color": 15629590, - "label": "x", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 5, - "y": 268, - "visible": true - }, - { - "target": "Stage", - "cmd": "getVar:", - "param": "y", - "color": 15629590, - "label": "y", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 5, - "y": 295, - "visible": true - }, - { - "target": "Stage", - "cmd": "getVar:", - "param": "z", - "color": 15629590, - "label": "z", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 78, - "y": 268, - "visible": true - }, - { - "objName": "Play", - "scripts": [[32, 33, [["whenClicked"], ["broadcast:", "start"]]]], - "sounds": [{ - "soundName": "pop", - "soundID": 0, - "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", - "sampleCount": 258, - "rate": 11025, - "format": "" - }], - "costumes": [{ - "costumeName": "costume1", - "baseLayerID": 2, - "baseLayerMD5": "30f811366ae3a53e6447932cc7f0212d.png", - "bitmapResolution": 2, - "rotationCenterX": 68, - "rotationCenterY": 115 - }], - "currentCostumeIndex": 0, - "scratchX": 2, - "scratchY": -48, - "scale": 1, - "direction": 90, - "rotationStyle": "normal", - "isDraggable": false, - "indexInLibrary": 1, - "visible": true, - "spriteInfo": { - } - }, - { - "target": "Stage", - "cmd": "getVar:", - "param": "d", - "color": 15629590, - "label": "d", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 5, - "y": 241, - "visible": true - }, - { - "target": "Stage", - "cmd": "getVar:", - "param": "a", - "color": 15629590, - "label": "a", - "mode": 1, - "sliderMin": 0, - "sliderMax": 100, - "isDiscrete": true, - "x": 78, - "y": 241, - "visible": true - }, - { - "objName": "Stop", - "scripts": [[45, 104, [["whenClicked"], ["stopScripts", "all"]]]], - "sounds": [{ - "soundName": "pop", - "soundID": 0, - "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", - "sampleCount": 258, - "rate": 11025, - "format": "" - }], - "costumes": [{ - "costumeName": "costume1", - "baseLayerID": 3, - "baseLayerMD5": "3de406f265b8d664406adf7c70762514.png", - "bitmapResolution": 2, - "rotationCenterX": 68, - "rotationCenterY": 70 - }], - "currentCostumeIndex": 0, - "scratchX": 121, - "scratchY": -33, - "scale": 1, - "direction": 90, - "rotationStyle": "normal", - "isDraggable": false, - "indexInLibrary": 2, - "visible": true, - "spriteInfo": { - } - }, - { - "listName": "D# Minor Pentatonic", - "contents": ["78", - "75", - "73", - "75", - "70", - "78", - "73", - "75", - "75", - "78", - "75", - "73", - "75", - "70", - "75", - "78", - "73", - "75", - "78", - "75", - "73", - "75", - "70", - "73", - "68", - "70", - "66", - "68", - "63"], - "isPersistent": false, - "x": 5, - "y": 32, - "width": 125, - "height": 206, - "visible": true - }], - "info": { - "spriteCount": 3, - "projectID": "118381369", - "videoOn": false, - "hasCloudData": false, - "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/53.0.2785.143 Safari\/537.36", - "scriptCount": 9, - "flashVersion": "MAC 23,0,0,185", - "swfVersion": "v450.1" - } -} +{"targets":[{"id":"m#69^p_G_]:NI,ZR#?iy","name":"Stage","isStage":true,"x":0,"y":0,"size":100,"direction":90,"draggable":false,"currentCostume":10,"costume":{"skinId":10,"name":"backdrop2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"dataFormat":"svg","assetId":"7da4181ee167de7b3f5d1a91880277ff"},"costumeCount":11,"visible":true,"rotationStyle":"all around","blocks":{"O7D.%E~TH^ULpAuHM8)@":{"id":"O7D.%E~TH^ULpAuHM8)@","opcode":"event_whenflagclicked","inputs":{},"fields":{},"next":"78+=E[P7b8!mBXMwQF`@","shadow":false,"x":138,"y":356.40000000000003,"topLevel":true,"parent":null},"78+=E[P7b8!mBXMwQF`@":{"id":"78+=E[P7b8!mBXMwQF`@","opcode":"control_forever","inputs":{"SUBSTACK":{"name":"SUBSTACK","block":"nlnk{2XqtVI*Qg,admbW","shadow":null}},"fields":{},"next":null,"shadow":false,"parent":"O7D.%E~TH^ULpAuHM8)@"},"nlnk{2XqtVI*Qg,admbW":{"id":"nlnk{2XqtVI*Qg,admbW","opcode":"looks_changeeffectby","inputs":{"EFFECT":{"name":"EFFECT","block":"*vh;qV87Q}5IP@sW=)wD","shadow":"*vh;qV87Q}5IP@sW=)wD"},"CHANGE":{"name":"CHANGE","block":"woYo[[v=PD(`R;qW{PZ%","shadow":"woYo[[v=PD(`R;qW{PZ%"}},"fields":{},"next":null,"shadow":false,"parent":"78+=E[P7b8!mBXMwQF`@"},"*vh;qV87Q}5IP@sW=)wD":{"id":"*vh;qV87Q}5IP@sW=)wD","opcode":"looks_effectmenu","inputs":{},"fields":{"EFFECT":{"name":"EFFECT","value":"color"}},"next":null,"topLevel":false,"parent":"nlnk{2XqtVI*Qg,admbW","shadow":true},"woYo[[v=PD(`R;qW{PZ%":{"id":"woYo[[v=PD(`R;qW{PZ%","opcode":"math_number","inputs":{},"fields":{"NUM":{"name":"NUM","value":1}},"next":null,"topLevel":false,"parent":"nlnk{2XqtVI*Qg,admbW","shadow":true}},"variables":{"6C~ysz%{q=IY/@VyR,}u":{"id":"6C~ysz%{q=IY/@VyR,}u","name":"vy","value":-3.5165000000000006,"type":""},"@M%UU5XBzcjofm,PEY-3":{"id":"@M%UU5XBzcjofm,PEY-3","name":"g","value":"-.5","type":""},"rNxv=Ia#{]Jvhmo{G6cf":{"id":"rNxv=Ia#{]Jvhmo{G6cf","name":"vx","value":1.4580000000000002,"type":""},"fc2rL3s1GcD4UyOOCaN(":{"id":"fc2rL3s1GcD4UyOOCaN(","name":"i","value":3, "type":""},"0A~.tga@}|%UW31FAhz5":{"id":"0A~.tga@}|%UW31FAhz5","name":"d1","value":0, "type":""},"^T|b;%@AzqwgHp},y98=":{"id":"^T|b;%@AzqwgHp},y98=","name":"x","value":233.9173278872492,"type":""},"6m`MGC(+0!,Mwj8xE2OB":{"id":"6m`MGC(+0!,Mwj8xE2OB","name":"acceleration","value":"-3","type":""}},"lists":{},"costumes":[{"skinId":9,"name":"blue sky2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"dataFormat":"svg","assetId":"7623e679b2baa2e7d48808614820844f"},{"skinId":13,"name":"woods","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"dataFormat":"svg","assetId":"1e0f7a4c932423b13250b5cb44928109"},{"skinId":5,"name":"party","bitmapResolution":1,"rotationCenterX":251,"rotationCenterY":189,"dataFormat":"svg","assetId":"108160d0e44d1c340182e31c9dc0758a"},{"skinId":3,"name":"boardwalk","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"de0e54cd11551566f044e7e6bc588b2c"},{"skinId":6,"name":"blue sky3","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":179,"dataFormat":"svg","assetId":"2024d59c1980e667e8f656134796e2c1"},{"skinId":14,"name":"underwater1","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"f339c6f31b11ea71d0fb8d607cec392e"},{"skinId":7,"name":"underwater2","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"1517c21786d2d0edc2f3037408d850bd"},{"skinId":8,"name":"stars","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"e87fed9c2a968dbeae8c94617e600e8c"},{"skinId":11,"name":"parking-ramp","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"a7832479977c166ca0057f2a99a73305"},{"skinId":12,"name":"backdrop1","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"dataFormat":"png","assetId":"f67dc7de38bac6fbb0ab68e46352521d"},{"skinId":10,"name":"backdrop2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"dataFormat":"svg","assetId":"7da4181ee167de7b3f5d1a91880277ff"}],"sounds":[{"format":"","rate":11025,"sampleCount":258,"soundID":-1,"name":"pop","md5":"83a9787d4cb6f3b7632b4ddfebf74367.wav","data":null,"dataFormat":"wav","assetId":"83a9787d4cb6f3b7632b4ddfebf74367","soundId":"yGa~fKV|[-9r)^[i+xMU"}]},{"id":"./Q-Mm#v_Ifl9@[}Ye^8","name":"Earth","isStage":false,"x":-10,"y":10,"size":100,"direction":90,"draggable":false,"currentCostume":0,"costume":{"skinId":4,"name":"earth","bitmapResolution":1,"rotationCenterX":72,"rotationCenterY":72,"dataFormat":"svg","assetId":"814197522984a302972998b1a7f92d91"},"costumeCount":1,"visible":true,"rotationStyle":"all around","blocks":{},"variables":{},"lists":{},"costumes":[{"skinId":4,"name":"earth","bitmapResolution":1,"rotationCenterX":72,"rotationCenterY":72,"dataFormat":"svg","assetId":"814197522984a302972998b1a7f92d91"}],"sounds":[{"format":"","rate":11025,"sampleCount":258,"soundID":-1,"name":"pop","md5":"83a9787d4cb6f3b7632b4ddfebf74367.wav","data":null,"dataFormat":"wav","assetId":"83a9787d4cb6f3b7632b4ddfebf74367","soundId":"MRZ+h9~E:8O(Fk,.zDVC"}]}],"meta":{"semver":"3.0.0","vm":"0.1.0-prerelease.1510327661-prerelease.1510327675","agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.75 Safari/537.36"}} diff --git a/test/fixtures/dispatch-test-service.js b/test/fixtures/dispatch-test-service.js new file mode 100644 index 00000000..3e01e68f --- /dev/null +++ b/test/fixtures/dispatch-test-service.js @@ -0,0 +1,15 @@ +class DispatchTestService { + returnFortyTwo () { + return 42; + } + + doubleArgument (x) { + return 2 * x; + } + + throwException () { + throw new Error('This is a test exception thrown by DispatchTest'); + } +} + +module.exports = DispatchTestService; diff --git a/test/fixtures/dispatch-test-worker-shim.js b/test/fixtures/dispatch-test-worker-shim.js new file mode 100644 index 00000000..ae11a495 --- /dev/null +++ b/test/fixtures/dispatch-test-worker-shim.js @@ -0,0 +1,19 @@ +const Module = require('module'); + +const callsite = require('callsite'); +const path = require('path'); + +const oldRequire = Module.prototype.require; +Module.prototype.require = function (target) { + if (target.indexOf('/') === -1) { + return oldRequire.apply(this, arguments); + } + + const stack = callsite(); + const callerFile = stack[2].getFileName(); + const callerDir = path.dirname(callerFile); + target = path.resolve(callerDir, target); + return oldRequire.call(this, target); +}; + +oldRequire(path.resolve(__dirname, 'dispatch-test-worker')); diff --git a/test/fixtures/dispatch-test-worker.js b/test/fixtures/dispatch-test-worker.js new file mode 100644 index 00000000..2c57fbb3 --- /dev/null +++ b/test/fixtures/dispatch-test-worker.js @@ -0,0 +1,11 @@ +const dispatch = require('../../src/dispatch/worker-dispatch'); +const DispatchTestService = require('./dispatch-test-service'); +const log = require('../../src/util/log'); + +dispatch.setService('RemoteDispatchTest', new DispatchTestService()); + +dispatch.waitForConnection.then(() => { + dispatch.call('test', 'onWorkerReady').catch(e => { + log(`Test worker failed to call onWorkerReady: ${JSON.stringify(e)}`); + }); +}); diff --git a/test/fixtures/event.sb2 b/test/fixtures/event.sb2 new file mode 100644 index 00000000..6533f292 Binary files /dev/null and b/test/fixtures/event.sb2 differ diff --git a/test/fixtures/events.json b/test/fixtures/events.json index d91370f6..01ebff74 100644 --- a/test/fixtures/events.json +++ b/test/fixtures/events.json @@ -60,6 +60,12 @@ "outerHTML": "" } }, + "createvariablewithentity": { + "name": "block", + "xml": { + "outerHTML": "this & that" + } + }, "createobscuredshadow": { "name": "block", "xml": { diff --git a/test/fixtures/extract.js b/test/fixtures/extract.js new file mode 100644 index 00000000..09826130 --- /dev/null +++ b/test/fixtures/extract.js @@ -0,0 +1,6 @@ +const AdmZip = require('adm-zip'); + +module.exports = function (path) { + const zip = new AdmZip(path); + return zip.readAsText('project.json', 'utf8'); +}; diff --git a/test/fixtures/fake-renderer.js b/test/fixtures/fake-renderer.js new file mode 100644 index 00000000..a6cc9fc3 --- /dev/null +++ b/test/fixtures/fake-renderer.js @@ -0,0 +1,66 @@ +const FakeRenderer = function () { + this.unused = ''; + this.x = 0; + this.y = 0; + this.order = 0; + this.spriteCount = 5; +}; + +FakeRenderer.prototype.createDrawable = function () { + return true; +}; + +FakeRenderer.prototype.getFencedPositionOfDrawable = function (d, p) { // eslint-disable-line no-unused-vars + return [p[0], p[1]]; +}; + +FakeRenderer.prototype.updateDrawableProperties = function (d, p) { // eslint-disable-line no-unused-vars + if (p.position) { + this.x = p.position[0]; + this.y = p.position[1]; + } + return true; +}; + +FakeRenderer.prototype.getSkinSize = function (d) { // eslint-disable-line no-unused-vars + return [0, 0]; +}; + +FakeRenderer.prototype.pick = function (x, y, a, b, d) { // eslint-disable-line no-unused-vars + return true; +}; + +FakeRenderer.prototype.isTouchingColor = function (d, c) { // eslint-disable-line no-unused-vars + return true; +}; + +FakeRenderer.prototype.setDrawableOrder = function (d, l, optA, optB) { // eslint-disable-line no-unused-vars + return true; +}; + +FakeRenderer.prototype.getBounds = function (d) { // eslint-disable-line no-unused-vars + return {left: this.x, right: this.x, top: this.y, bottom: this.y}; +}; + +FakeRenderer.prototype.setDrawableOrder = function (d, a, optA, optB) { // eslint-disable-line no-unused-vars + if (d === 999) return 1; // fake for test case + if (optA) { + a += this.order; + } + if (optB) { + a = Math.max(a, optB); + } + a = Math.max(a, 0); + this.order = Math.min(a, this.spriteCount); + return this.order; +}; + +FakeRenderer.prototype.pick = function (x, y, a, b, c) { // eslint-disable-line no-unused-vars + return c[0]; +}; + +FakeRenderer.prototype.isTouchingColor = function (a, b) { // eslint-disable-line no-unused-vars + return false; +}; + +module.exports = FakeRenderer; diff --git a/test/fixtures/hat-execution-order.sb2 b/test/fixtures/hat-execution-order.sb2 new file mode 100644 index 00000000..f9a7efdc Binary files /dev/null and b/test/fixtures/hat-execution-order.sb2 differ diff --git a/test/fixtures/looks.sb2 b/test/fixtures/looks.sb2 new file mode 100644 index 00000000..aafe280e Binary files /dev/null and b/test/fixtures/looks.sb2 differ diff --git a/test/fixtures/loudness-hat-block.sb2 b/test/fixtures/loudness-hat-block.sb2 new file mode 100644 index 00000000..3dfe9318 Binary files /dev/null and b/test/fixtures/loudness-hat-block.sb2 differ diff --git a/test/fixtures/make-test-storage.js b/test/fixtures/make-test-storage.js new file mode 100644 index 00000000..0179ca34 --- /dev/null +++ b/test/fixtures/make-test-storage.js @@ -0,0 +1,47 @@ +const ScratchStorage = require('scratch-storage'); + +const ASSET_SERVER = 'https://cdn.assets.scratch.mit.edu/'; +const PROJECT_SERVER = 'https://cdn.projects.scratch.mit.edu/'; + +/** + * @param {Asset} asset - calculate a URL for this asset. + * @returns {string} a URL to download a project file. + */ +const getProjectUrl = function (asset) { + const assetIdParts = asset.assetId.split('.'); + const assetUrlParts = [PROJECT_SERVER, 'internalapi/project/', assetIdParts[0], '/get/']; + if (assetIdParts[1]) { + assetUrlParts.push(assetIdParts[1]); + } + return assetUrlParts.join(''); +}; + +/** + * @param {Asset} asset - calculate a URL for this asset. + * @returns {string} a URL to download a project asset (PNG, WAV, etc.) + */ +const getAssetUrl = function (asset) { + const assetUrlParts = [ + ASSET_SERVER, + 'internalapi/asset/', + asset.assetId, + '.', + asset.dataFormat, + '/get/' + ]; + return assetUrlParts.join(''); +}; + +/** + * Construct a new instance of ScratchStorage and provide it with default web sources. + * @returns {ScratchStorage} - an instance of ScratchStorage, ready to be used for tests. + */ +const makeTestStorage = function () { + const storage = new ScratchStorage(); + const AssetType = storage.AssetType; + storage.addWebSource([AssetType.Project], getProjectUrl); + storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl); + return storage; +}; + +module.exports = makeTestStorage; diff --git a/test/fixtures/motion.sb2 b/test/fixtures/motion.sb2 new file mode 100644 index 00000000..08c56354 Binary files /dev/null and b/test/fixtures/motion.sb2 differ diff --git a/test/fixtures/pen.sb2 b/test/fixtures/pen.sb2 new file mode 100644 index 00000000..4a0c7573 Binary files /dev/null and b/test/fixtures/pen.sb2 differ diff --git a/test/fixtures/procedure.sb2 b/test/fixtures/procedure.sb2 new file mode 100644 index 00000000..7c6c2d60 Binary files /dev/null and b/test/fixtures/procedure.sb2 differ diff --git a/test/fixtures/sensing.sb2 b/test/fixtures/sensing.sb2 new file mode 100644 index 00000000..c7fa5d64 Binary files /dev/null and b/test/fixtures/sensing.sb2 differ diff --git a/test/fixtures/sound.sb2 b/test/fixtures/sound.sb2 new file mode 100644 index 00000000..dce594b3 Binary files /dev/null and b/test/fixtures/sound.sb2 differ diff --git a/test/fixtures/sprite.json b/test/fixtures/sprite.json new file mode 100755 index 00000000..c2f15acd --- /dev/null +++ b/test/fixtures/sprite.json @@ -0,0 +1,38 @@ +{ + "objName": "Sprite1", + "sounds": [{ + "soundName": "meow", + "soundID": 0, + "md5": "83c36d806dc92327b9e7049a565c6bff.wav", + "sampleCount": 18688, + "rate": 22050, + "format": "" + }], + "costumes": [{ + "costumeName": "costume1", + "baseLayerID": 0, + "baseLayerMD5": "f9a1c175dbe2e5dee472858dd30d16bb.svg", + "bitmapResolution": 1, + "rotationCenterX": 47, + "rotationCenterY": 55 + }, + { + "costumeName": "costume2", + "baseLayerID": 1, + "baseLayerMD5": "6e8bd9ae68fdb02b7e1e3df656a75635.svg", + "bitmapResolution": 1, + "rotationCenterX": 47, + "rotationCenterY": 55 + }], + "currentCostumeIndex": 0, + "scratchX": 0, + "scratchY": 0, + "scale": 1, + "direction": 90, + "rotationStyle": "normal", + "isDraggable": false, + "indexInLibrary": 100000, + "visible": true, + "spriteInfo": { + } +} \ No newline at end of file diff --git a/test/fixtures/stack-click.sb2 b/test/fixtures/stack-click.sb2 new file mode 100644 index 00000000..8b735b73 Binary files /dev/null and b/test/fixtures/stack-click.sb2 differ diff --git a/test/integration/clone-cleanup.js b/test/integration/clone-cleanup.js new file mode 100644 index 00000000..8b801a00 --- /dev/null +++ b/test/integration/clone-cleanup.js @@ -0,0 +1,96 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const projectUri = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2'); +const project = extract(projectUri); + +test('clone-cleanup', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + /** + * Track which step of the project is currently under test. + * @type {number} + */ + let testStep = -1; + + /** + * We test using setInterval; track the interval ID here so we can cancel it. + * @type {object} + */ + let testInterval = null; + + const verifyCounts = (expectedClones, extraThreads) => { + // stage plus one sprite, plus clones + t.strictEqual(vm.runtime.targets.length, 2 + expectedClones, + `target count at step ${testStep}`); + + // the stage should never have any clones + t.strictEqual(vm.runtime.targets[0].sprite.clones.length, 1, + `stage clone count at step ${testStep}`); + + // check sprite clone count (+1 for original) + t.strictEqual(vm.runtime.targets[1].sprite.clones.length, 1 + expectedClones, + `sprite clone count at step ${testStep}`); + + // thread count isn't directly tied to clone count since threads can end + t.strictEqual(vm.runtime.threads.length, extraThreads + (2 * expectedClones), + `thread count at step ${testStep}`); + }; + + const testNextStep = () => { + ++testStep; + switch (testStep) { + case 0: + // Project has started, main thread running, no clones yet + verifyCounts(0, 1); + break; + + case 1: + // 10 clones have been created, main thread still running + verifyCounts(10, 1); + break; + + case 2: + // The first batch of clones has deleted themselves; main thread still running + verifyCounts(0, 1); + break; + + case 3: + // The second batch of clones has been created and the main thread has ended + verifyCounts(10, 0); + break; + + case 4: + // The second batch of clones has deleted themselves; everything is finished + verifyCounts(0, 0); + + clearInterval(testInterval); + t.end(); + process.nextTick(process.exit); + break; + } + }; + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + + // Verify initial state: no clones, nothing running ("step -1") + verifyCounts(0, 0); + + vm.greenFlag(); + + // Every second, advance the testing step + testInterval = setInterval(testNextStep, 1000); + }); + }); + +}); diff --git a/test/integration/complex.js b/test/integration/complex.js new file mode 100644 index 00000000..331f2a98 --- /dev/null +++ b/test/integration/complex.js @@ -0,0 +1,99 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const projectUri = path.resolve(__dirname, '../fixtures/complex.sb2'); +const project = extract(projectUri); + +const spriteUri = path.resolve(__dirname, '../fixtures/sprite.json'); +const sprite = fs.readFileSync(spriteUri, 'utf8'); + +test('complex', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length === 0); + t.end(); + process.nextTick(process.exit); + }); + + // Manipulate each target + vm.on('targetsUpdate', data => { + const targets = data.targetList; + for (const i in targets) { + if (targets[i].isStage === true) continue; + if (targets[i].name.match(/test/)) continue; + + vm.setEditingTarget(targets[i].id); + vm.renameSprite(targets[i].id, 'test'); + vm.postSpriteInfo({ + x: 0, + y: 10, + direction: 90, + draggable: true, + rotationStyle: 'all around', + visible: true + }); + vm.addCostume( + 'f9a1c175dbe2e5dee472858dd30d16bb.svg', + { + name: 'costume1', + baseLayerID: 0, + baseLayerMD5: 'f9a1c175dbe2e5dee472858dd30d16bb.svg', + bitmapResolution: 1, + rotationCenterX: 47, + rotationCenterY: 55 + } + ); + } + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // Post IO data + vm.postIOData('mouse', { + isDown: true, + x: 0, + y: 10, + canvasWidth: 100, + canvasHeight: 100 + }); + + // Add sprite + vm.addSprite2(sprite); + + // Add backdrop + vm.addBackdrop( + '6b3d87ba2a7f89be703163b6c1d4c964.png', + { + name: 'baseball-field', + baseLayerID: 26, + baseLayerMD5: '6b3d87ba2a7f89be703163b6c1d4c964.png', + bitmapResolution: 2, + rotationCenterX: 480, + rotationCenterY: 360 + } + ); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); + +}); diff --git a/test/integration/control.js b/test/integration/control.js new file mode 100644 index 00000000..66b4c028 --- /dev/null +++ b/test/integration/control.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/control.sb2'); +const project = extract(uri); + +test('control', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length > 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + }); + }); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); +}); diff --git a/test/integration/data.js b/test/integration/data.js new file mode 100644 index 00000000..c6cd71d1 --- /dev/null +++ b/test/integration/data.js @@ -0,0 +1,37 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/data.sb2'); +const project = extract(uri); + +test('data', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', () => { + // @todo Additional tests + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/event.js b/test/integration/event.js new file mode 100644 index 00000000..c4f3ee58 --- /dev/null +++ b/test/integration/event.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/event.sb2'); +const project = extract(uri); + +test('event', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length > 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/hat-execution-order.js b/test/integration/hat-execution-order.js new file mode 100644 index 00000000..3e5e92a2 --- /dev/null +++ b/test/integration/hat-execution-order.js @@ -0,0 +1,43 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const projectUri = path.resolve(__dirname, '../fixtures/hat-execution-order.sb2'); +const project = extract(projectUri); + +test('complex', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length === 0); + + const resultKey = Object.keys(vm.runtime.targets[0].variables)[0]; + const results = vm.runtime.targets[0].variables[resultKey].value; + t.deepEqual(results, ['3', '2', '1', 'stage']); + + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/hat-threads-run-every-frame.js b/test/integration/hat-threads-run-every-frame.js new file mode 100644 index 00000000..3f0fd2bf --- /dev/null +++ b/test/integration/hat-threads-run-every-frame.js @@ -0,0 +1,194 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); +const Thread = require('../../src/engine/thread'); +const Runtime = require('../../src/engine/runtime'); + +const projectUri = path.resolve(__dirname, '../fixtures/loudness-hat-block.sb2'); +const project = extract(projectUri); + +const checkIsHatThread = (t, vm, hatThread) => { + t.equal(hatThread.stackClick, false); + t.equal(hatThread.updateMonitor, false); + const blockContainer = hatThread.target.blocks; + const opcode = blockContainer.getOpcode(blockContainer.getBlock(hatThread.topBlock)); + t.assert(vm.runtime.getIsEdgeActivatedHat(opcode)); +}; + +const checkIsStackClickThread = (t, vm, stackClickThread) => { + t.equal(stackClickThread.stackClick, true); + t.equal(stackClickThread.updateMonitor, false); +}; + +/** + * loudness-hat-block.sb2 contains a single stack + * when loudness > 10 + * change color effect by 25 + * The intention is to make sure that the hat block condition is evaluated + * on each frame. + */ +test('edge activated hat thread runs once every frame', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Check that the hat thread is added again when another step is taken + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * If the hat doesn't finish evaluating within one frame, it shouldn't be added again + * on the next frame. (We skip execution by setting the step time to 0) + */ +test('edge activated hat thread not added twice', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + const prevThread = vm.runtime.threads[0]; + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + + // Check that no new threads are added when another step is taken + vm.runtime._step(); + // There should now be one done hat thread and one new hat thread to run + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0] === prevThread); + t.end(); + }); + }); +}); + +/** + * When adding a stack click thread first, make sure that the edge activated hat thread and + * the stack click thread are both pushed and run (despite having the same top block) + */ +test('edge activated hat thread does not interrupt stack click thread', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Add stack click thread on this hat + vm.runtime.toggleScript(vm.runtime.threads[0].topBlock, {stackClick: true}); + + // Check that the hat thread is added again when another step is taken + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 2); + let hatThread; + let stackClickThread; + if (vm.runtime.threads[0].stackClick) { + stackClickThread = vm.runtime.threads[0]; + hatThread = vm.runtime.threads[1]; + } else { + stackClickThread = vm.runtime.threads[1]; + hatThread = vm.runtime.threads[0]; + } + checkIsHatThread(t, vm, hatThread); + checkIsStackClickThread(t, vm, stackClickThread); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.assert(vm.runtime.threads[1].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * When adding the hat thread first, make sure that the edge activated hat thread and + * the stack click thread are both pushed and run (despite having the same top block) + */ +test('edge activated hat thread does not interrupt stack click thread', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + + // Add stack click thread on this hat + vm.runtime.toggleScript(vm.runtime.threads[0].topBlock, {stackClick: true}); + + // Check that the hat thread is added again when another step is taken + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 2); + let hatThread; + let stackClickThread; + if (vm.runtime.threads[0].stackClick) { + stackClickThread = vm.runtime.threads[0]; + hatThread = vm.runtime.threads[1]; + } else { + stackClickThread = vm.runtime.threads[1]; + hatThread = vm.runtime.threads[0]; + } + checkIsHatThread(t, vm, hatThread); + checkIsStackClickThread(t, vm, stackClickThread); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.assert(vm.runtime.threads[1].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); diff --git a/test/integration/import-sb2-from-object.js b/test/integration/import-sb2-from-object.js new file mode 100644 index 00000000..c53234f1 --- /dev/null +++ b/test/integration/import-sb2-from-object.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/default.sb2'); +const project = JSON.parse(extract(uri)); + +test('default', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length === 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/import_sb2.js b/test/integration/import_sb2.js new file mode 100644 index 00000000..ec553dd9 --- /dev/null +++ b/test/integration/import_sb2.js @@ -0,0 +1,54 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); + +const renderedTarget = require('../../src/sprites/rendered-target'); +const runtime = require('../../src/engine/runtime'); +const sb2 = require('../../src/serialization/sb2'); + +test('spec', t => { + t.type(sb2.deserialize, 'function'); + t.end(); +}); + +test('default', t => { + // Get SB2 JSON (string) + const uri = path.resolve(__dirname, '../fixtures/default.sb2'); + const file = extract(uri); + const json = JSON.parse(file); + + // Create runtime instance & load SB2 into it + const rt = new runtime(); + rt.attachStorage(makeTestStorage()); + sb2.deserialize(json, rt).then(({targets}) => { + // Test + t.type(file, 'string'); + t.type(json, 'object'); + t.type(rt, 'object'); + t.type(targets, 'object'); + + t.ok(targets[0] instanceof renderedTarget); + t.type(targets[0].id, 'string'); + t.type(targets[0].blocks, 'object'); + t.type(targets[0].variables, 'object'); + t.type(targets[0].lists, 'object'); + + t.equal(targets[0].isOriginal, true); + t.equal(targets[0].currentCostume, 0); + t.equal(targets[0].isOriginal, true); + t.equal(targets[0].isStage, true); + + t.ok(targets[1] instanceof renderedTarget); + t.type(targets[1].id, 'string'); + t.type(targets[1].blocks, 'object'); + t.type(targets[1].variables, 'object'); + t.type(targets[1].lists, 'object'); + + t.equal(targets[1].isOriginal, true); + t.equal(targets[1].currentCostume, 0); + t.equal(targets[1].isOriginal, true); + t.equal(targets[1].isStage, false); + t.end(); + }); +}); diff --git a/test/integration/index.js b/test/integration/index.js deleted file mode 100644 index 06bdcf37..00000000 --- a/test/integration/index.js +++ /dev/null @@ -1,26 +0,0 @@ -var test = require('tap').test; -var VirtualMachine = require('../../src/index'); - -test('spec', function (t) { - var vm = new VirtualMachine(); - - t.type(VirtualMachine, 'function'); - t.type(vm, 'object'); - t.end(); -}); - -test('create', function (t) { - t.end(); -}); - -test('move', function (t) { - t.end(); -}); - -test('change', function (t) { - t.end(); -}); - -test('delete', function (t) { - t.end(); -}); diff --git a/test/integration/internal-extension.js b/test/integration/internal-extension.js new file mode 100644 index 00000000..52ac10a5 --- /dev/null +++ b/test/integration/internal-extension.js @@ -0,0 +1,51 @@ +const test = require('tap').test; +const Worker = require('tiny-worker'); + +const dispatch = require('../../src/dispatch/central-dispatch'); +const VirtualMachine = require('../../src/virtual-machine'); + +// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. +dispatch.workerClass = Worker; + +class TestInternalExtension { + constructor () { + this.status = {}; + this.status.constructorCalled = true; + } + + getInfo () { + this.status.getInfoCalled = true; + return { + id: 'test-internal-extension', + name: 'Test Internal Extension', + blocks: [ + { + opcode: 'go' + } + ] + }; + } + + go () { + this.status.goCalled = true; + } +} + +test('internal extension', t => { + const vm = new VirtualMachine(); + + const extension = new TestInternalExtension(); + t.ok(extension.status.constructorCalled); + + t.notOk(extension.status.getInfoCalled); + return vm.extensionManager._registerInternalExtension(extension).then(() => { + t.ok(extension.status.getInfoCalled); + + const func = vm.runtime.getOpcodeFunction('test-internal-extension.go'); + t.type(func, 'function'); + + t.notOk(extension.status.goCalled); + func(); + t.ok(extension.status.goCalled); + }); +}); diff --git a/test/integration/looks.js b/test/integration/looks.js new file mode 100644 index 00000000..87464649 --- /dev/null +++ b/test/integration/looks.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/looks.sb2'); +const project = extract(uri); + +test('looks', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length === 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/monitor-threads-run-every-frame.js b/test/integration/monitor-threads-run-every-frame.js new file mode 100644 index 00000000..daf33693 --- /dev/null +++ b/test/integration/monitor-threads-run-every-frame.js @@ -0,0 +1,117 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); +const Thread = require('../../src/engine/thread'); +const Runtime = require('../../src/engine/runtime'); + +const projectUri = path.resolve(__dirname, '../fixtures/default.sb2'); +const project = extract(projectUri); + +const checkMonitorThreadPresent = (t, threads) => { + t.equal(threads.length, 1); + const monitorThread = threads[0]; + t.equal(monitorThread.stackClick, false); + t.equal(monitorThread.updateMonitor, true); + t.equal(monitorThread.topBlock.toString(), 'sensing_timer'); +}; + +/** + * Creates a monitor and then checks if it gets run every frame. + */ +/* TODO: when loadProject loads monitors, we can create a project with a monitor and will + * not have to do the create monitor step manually. + */ +test('monitor thread runs every frame', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + + // Manually populate the monitor block and set its isMonitored to true. + vm.runtime.monitorBlocks.createBlock({ + id: 'sensing_timer', + opcode: 'sensing_timer', + inputs: {}, + fields: {}, + next: null, + topLevel: true, + parent: null, + shadow: false, + isMonitored: true, + x: '0', + y: '0' + }); + + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Check that both are added again when another step is taken + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * If the monitor doesn't finish evaluating within one frame, it shouldn't be added again + * on the next frame. (We skip execution by setting the step time to 0) + */ +test('monitor thread not added twice', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + + // Manually populate the monitor block and set its isMonitored to true. + vm.runtime.monitorBlocks.createBlock({ + id: 'sensing_timer', + opcode: 'sensing_timer', + inputs: {}, + fields: {}, + next: null, + topLevel: true, + parent: null, + shadow: false, + isMonitored: true, + x: '0', + y: '0' + }); + + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + const prevThread = vm.runtime.threads[0]; + + // Check that both are added again when another step is taken + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.equal(vm.runtime.threads[0], prevThread); + t.end(); + }); + }); +}); diff --git a/test/integration/motion.js b/test/integration/motion.js new file mode 100644 index 00000000..689b2187 --- /dev/null +++ b/test/integration/motion.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/motion.sb2'); +const project = extract(uri); + +test('motion', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length > 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/pen.js b/test/integration/pen.js new file mode 100644 index 00000000..bb9f7c82 --- /dev/null +++ b/test/integration/pen.js @@ -0,0 +1,61 @@ +const Worker = require('tiny-worker'); +const path = require('path'); +const test = require('tap').test; + +const Scratch3PenBlocks = require('../../src/extensions/scratch3_pen/index.js'); +const VirtualMachine = require('../../src/index'); +const dispatch = require('../../src/dispatch/central-dispatch'); + +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); + +const uri = path.resolve(__dirname, '../fixtures/pen.sb2'); +const project = extract(uri); + +// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. +dispatch.workerClass = Worker; + +test('pen', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', () => { + // @todo Additional tests + + const catSprite = vm.runtime.targets[1].sprite; + const [originalCat, cloneCat] = catSprite.clones; + t.notStrictEqual(originalCat, cloneCat); + + /** @type {PenState} */ + const originalPenState = originalCat.getCustomState(Scratch3PenBlocks.STATE_KEY); + + /** @type {PenState} */ + const clonePenState = cloneCat.getCustomState(Scratch3PenBlocks.STATE_KEY); + + t.notStrictEqual(originalPenState, clonePenState); + t.equal(originalPenState.penAttributes.diameter, 51); + t.equal(clonePenState.penAttributes.diameter, 42); + + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project) + .then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/procedure.js b/test/integration/procedure.js new file mode 100644 index 00000000..e62f2b10 --- /dev/null +++ b/test/integration/procedure.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/procedure.sb2'); +const project = extract(uri); + +test('procedure', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length === 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/sb3-roundtrip.js b/test/integration/sb3-roundtrip.js new file mode 100644 index 00000000..5704e33b --- /dev/null +++ b/test/integration/sb3-roundtrip.js @@ -0,0 +1,110 @@ +const test = require('tap').test; + +const Blocks = require('../../src/engine/blocks'); +const Clone = require('../../src/util/clone'); +const {loadCostume} = require('../../src/import/load-costume'); +const {loadSound} = require('../../src/import/load-sound'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const Runtime = require('../../src/engine/runtime'); +const sb3 = require('../../src/serialization/sb3'); +const Sprite = require('../../src/sprites/sprite'); + +const defaultCostumeInfo = { + bitmapResolution: 1, + rotationCenterX: 0, + rotationCenterY: 0 +}; + +const defaultSoundInfo = { +}; + +test('sb3-roundtrip', t => { + const runtime1 = new Runtime(); + runtime1.attachStorage(makeTestStorage()); + + const runtime2 = new Runtime(); + runtime2.attachStorage(makeTestStorage()); + + const testRuntimeState = (label, runtime) => { + t.strictEqual(runtime.targets.length, 2, `${label}: target count`); + const [stageClone, spriteClone] = runtime.targets; + + t.strictEqual(stageClone.isOriginal, true); + t.strictEqual(stageClone.isStage, true); + + const stage = stageClone.sprite; + t.strictEqual(stage.name, 'Stage'); + t.strictEqual(stage.clones.length, 1); + t.strictEqual(stage.clones[0], stageClone); + + t.strictEqual(stage.costumes.length, 1); + const [building] = stage.costumes; + t.strictEqual(building.assetId, 'fe5e3566965f9de793beeffce377d054'); + t.strictEqual(building.dataFormat, 'jpg'); + + t.strictEqual(stage.sounds.length, 0); + + t.strictEqual(spriteClone.isOriginal, true); + t.strictEqual(spriteClone.isStage, false); + + const sprite = spriteClone.sprite; + t.strictEqual(sprite.name, 'Sprite'); + t.strictEqual(sprite.clones.length, 1); + t.strictEqual(sprite.clones[0], spriteClone); + + t.strictEqual(sprite.costumes.length, 2); + const [cat, squirrel] = sprite.costumes; + t.strictEqual(cat.assetId, 'f88bf1935daea28f8ca098462a31dbb0'); + t.strictEqual(cat.dataFormat, 'svg'); + t.strictEqual(squirrel.assetId, '7e24c99c1b853e52f8e7f9004416fa34'); + t.strictEqual(squirrel.dataFormat, 'png'); + + t.strictEqual(sprite.sounds.length, 1); + const [meow] = sprite.sounds; + t.strictEqual(meow.md5, '83c36d806dc92327b9e7049a565c6bff.wav'); + }; + + const loadThings = Promise.all([ + loadCostume('fe5e3566965f9de793beeffce377d054.jpg', Clone.simple(defaultCostumeInfo), runtime1), + loadCostume('f88bf1935daea28f8ca098462a31dbb0.svg', Clone.simple(defaultCostumeInfo), runtime1), + loadCostume('7e24c99c1b853e52f8e7f9004416fa34.png', Clone.simple(defaultCostumeInfo), runtime1), + loadSound(Object.assign({md5: '83c36d806dc92327b9e7049a565c6bff.wav'}, defaultSoundInfo), runtime1) + ]); + + const installThings = loadThings.then(results => { + const [building, cat, squirrel, meow] = results; + + const stageBlocks = new Blocks(); + const stage = new Sprite(stageBlocks, runtime1); + stage.name = 'Stage'; + stage.costumes = [building]; + stage.sounds = []; + const stageClone = stage.createClone(); + stageClone.isStage = true; + + const spriteBlocks = new Blocks(); + const sprite = new Sprite(spriteBlocks, runtime1); + sprite.name = 'Sprite'; + sprite.costumes = [cat, squirrel]; + sprite.sounds = [meow]; + const spriteClone = sprite.createClone(); + + runtime1.targets = [stageClone, spriteClone]; + + testRuntimeState('original', runtime1); + }); + + const serializeAndDeserialize = installThings.then(() => { + // Doing a JSON `stringify` and `parse` here more accurately simulate a save/load cycle. In particular: + // 1. it ensures that any non-serializable data is thrown away, and + // 2. `sb3.deserialize` and its helpers do some `hasOwnProperty` checks which fail on the object returned by + // `sb3.serialize` but succeed if that object is "flattened" in this way. + const serializedState = JSON.parse(JSON.stringify(sb3.serialize(runtime1))); + return sb3.deserialize(serializedState, runtime2); + }); + + return serializeAndDeserialize.then(({targets}) => { + runtime2.targets = targets; + testRuntimeState('copy', runtime2); + }); +}); diff --git a/test/integration/sensing.js b/test/integration/sensing.js new file mode 100644 index 00000000..1d7d3085 --- /dev/null +++ b/test/integration/sensing.js @@ -0,0 +1,38 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const uri = path.resolve(__dirname, '../fixtures/sensing.sb2'); +const project = extract(uri); + +test('sensing', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length > 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/sound.js b/test/integration/sound.js new file mode 100644 index 00000000..1c5f20dc --- /dev/null +++ b/test/integration/sound.js @@ -0,0 +1,43 @@ +const Worker = require('tiny-worker'); +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); +const dispatch = require('../../src/dispatch/central-dispatch'); + +const uri = path.resolve(__dirname, '../fixtures/sound.sb2'); +const project = extract(uri); + +// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. +dispatch.workerClass = Worker; + +test('sound', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + t.ok(threads.length > 0); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/integration/stack-click.js b/test/integration/stack-click.js new file mode 100644 index 00000000..1243eed6 --- /dev/null +++ b/test/integration/stack-click.js @@ -0,0 +1,59 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const projectUri = path.resolve(__dirname, '../fixtures/stack-click.sb2'); +const project = extract(projectUri); + +/** + * stack-click.sb2 contains a sprite at (0, 0) with a single stack + * when timer > 100000000 + * move 100 steps + * The intention is to make sure that the stack can be activated by a stack click + * even when the hat predicate is false. + */ +test('stack click activates the stack', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', () => { + // The sprite should have moved 100 to the right + t.equal(vm.editingTarget.x, 100); + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + const blockContainer = vm.runtime.targets[1].blocks; + const allBlocks = blockContainer._blocks; + + // Confirm the editing target is initially at 0 + t.equal(vm.editingTarget.x, 0); + + // Find hat for greater than and click it + for (const blockId in allBlocks) { + if (allBlocks[blockId].opcode === 'event_whengreaterthan') { + blockContainer.blocklyListen({ + blockId: blockId, + element: 'stackclick' + }, vm.runtime); + } + } + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +}); diff --git a/test/unit/blocks_control.js b/test/unit/blocks_control.js new file mode 100644 index 00000000..e2eb383a --- /dev/null +++ b/test/unit/blocks_control.js @@ -0,0 +1,130 @@ +const test = require('tap').test; +const Control = require('../../src/blocks/scratch3_control'); +const Runtime = require('../../src/engine/runtime'); + +test('getPrimitives', t => { + const rt = new Runtime(); + const c = new Control(rt); + t.type(c.getPrimitives(), 'object'); + t.end(); +}); + +test('repeat', t => { + const rt = new Runtime(); + const c = new Control(rt); + + // Test harness (mocks `util`) + let i = 0; + const repeat = 10; + const util = { + stackFrame: Object.create(null), + startBranch: function () { + i++; + c.repeat({TIMES: repeat}, util); + } + }; + + // Execute test + c.repeat({TIMES: 10}, util); + t.strictEqual(util.stackFrame.loopCounter, -1); + t.strictEqual(i, repeat); + t.end(); +}); + +test('repeatUntil', t => { + const rt = new Runtime(); + const c = new Control(rt); + + // Test harness (mocks `util`) + let i = 0; + const repeat = 10; + const util = { + stackFrame: Object.create(null), + startBranch: function () { + i++; + c.repeatUntil({CONDITION: (i === repeat)}, util); + } + }; + + // Execute test + c.repeatUntil({CONDITION: (i === repeat)}, util); + t.strictEqual(i, repeat); + t.end(); +}); + +test('forever', t => { + const rt = new Runtime(); + const c = new Control(rt); + + // Test harness (mocks `util`) + let i = 0; + const util = { + startBranch: function (branchNum, isLoop) { + i++; + t.strictEqual(branchNum, 1); + t.strictEqual(isLoop, true); + } + }; + + // Execute test + c.forever(null, util); + t.strictEqual(i, 1); + t.end(); +}); + +test('if / ifElse', t => { + const rt = new Runtime(); + const c = new Control(rt); + + // Test harness (mocks `util`) + let i = 0; + const util = { + startBranch: function (branchNum) { + i += branchNum; + } + }; + + // Execute test + c.if({CONDITION: true}, util); + t.strictEqual(i, 1); + c.if({CONDITION: false}, util); + t.strictEqual(i, 1); + c.ifElse({CONDITION: true}, util); + t.strictEqual(i, 2); + c.ifElse({CONDITION: false}, util); + t.strictEqual(i, 4); + t.end(); +}); + +test('stop', t => { + const rt = new Runtime(); + const c = new Control(rt); + + // Test harness (mocks `util`) + const state = { + stopAll: 0, + stopOtherTargetThreads: 0, + stopThisScript: 0 + }; + const util = { + stopAll: function () { + state.stopAll++; + }, + stopOtherTargetThreads: function () { + state.stopOtherTargetThreads++; + }, + stopThisScript: function () { + state.stopThisScript++; + } + }; + + // Execute test + c.stop({STOP_OPTION: 'all'}, util); + c.stop({STOP_OPTION: 'other scripts in sprite'}, util); + c.stop({STOP_OPTION: 'other scripts in stage'}, util); + c.stop({STOP_OPTION: 'this script'}, util); + t.strictEqual(state.stopAll, 1); + t.strictEqual(state.stopOtherTargetThreads, 2); + t.strictEqual(state.stopThisScript, 1); + t.end(); +}); diff --git a/test/unit/blocks_event.js b/test/unit/blocks_event.js new file mode 100644 index 00000000..672ea332 --- /dev/null +++ b/test/unit/blocks_event.js @@ -0,0 +1,98 @@ +const test = require('tap').test; + +const Blocks = require('../../src/engine/blocks'); +const BlockUtility = require('../../src/engine/block-utility'); +const Event = require('../../src/blocks/scratch3_event'); +const Runtime = require('../../src/engine/runtime'); +const Target = require('../../src/engine/target'); +const Thread = require('../../src/engine/thread'); +const Variable = require('../../src/engine/variable'); + +test('#760 - broadcastAndWait', t => { + const broadcastAndWaitBlock = { + id: 'broadcastAndWaitBlock', + fields: { + BROADCAST_OPTION: { + id: 'testBroadcastID', + value: 'message' + } + }, + inputs: Object, + block: 'fakeBlock', + opcode: 'event_broadcastandwait', + next: null, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + const receiveMessageBlock = { + id: 'receiveMessageBlock', + fields: { + BROADCAST_OPTION: { + id: 'testBroadcastID', + value: 'message' + } + }, + inputs: Object, + block: 'fakeBlock', + opcode: 'event_whenbroadcastreceived', + next: null, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + + const rt = new Runtime(); + const e = new Event(rt); + const b = new Blocks(); + b.createBlock(broadcastAndWaitBlock); + b.createBlock(receiveMessageBlock); + const tgt = new Target(rt, b); + tgt.isStage = true; + tgt.createVariable('testBroadcastID', 'message', Variable.BROADCAST_MESSAGE_TYPE); + rt.targets.push(tgt); + + let th = rt._pushThread('broadcastAndWaitBlock', t); + const util = new BlockUtility(); + util.sequencer = rt.sequencer; + util.thread = th; + util.runtime = rt; + + // creates threads + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(rt.threads.length, 2); + t.strictEqual(rt.threads[1].topBlock, 'receiveMessageBlock'); + // yields when some thread is active + t.strictEqual(th.status, Thread.STATUS_YIELD); + th.status = Thread.STATUS_RUNNING; + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(th.status, Thread.STATUS_YIELD); + // does not yield once all threads are done + th.status = Thread.STATUS_RUNNING; + rt.threads[1].status = Thread.STATUS_DONE; + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(th.status, Thread.STATUS_RUNNING); + + // restarts done threads that are in runtime threads + th = rt._pushThread('broadcastAndWaitBlock', tgt); + util.thread = th; + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(rt.threads.length, 3); + t.strictEqual(rt.threads[1].status, Thread.STATUS_RUNNING); + t.strictEqual(th.status, Thread.STATUS_YIELD); + // yields when some restarted thread is active + th.status = Thread.STATUS_RUNNING; + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(th.status, Thread.STATUS_YIELD); + // does not yield once all threads are done + th.status = Thread.STATUS_RUNNING; + rt.threads[1].status = Thread.STATUS_DONE; + e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util); + t.strictEqual(th.status, Thread.STATUS_RUNNING); + + t.end(); +}); diff --git a/test/unit/blocks_looks.js b/test/unit/blocks_looks.js new file mode 100644 index 00000000..1506d7f8 --- /dev/null +++ b/test/unit/blocks_looks.js @@ -0,0 +1,52 @@ +const test = require('tap').test; +const Looks = require('../../src/blocks/scratch3_looks'); +const util = { + target: { + currentCostume: 0, // Internally, current costume is 0 indexed + sprite: { + costumes: [ + {name: 'first name'}, + {name: 'second name'}, + {name: 'third name'} + ] + } + } +}; + +const fakeRuntime = { + getTargetForStage: () => util.target, // Just return the dummy target above. + on: () => {} // Stub out listener methods used in constructor. +}; +const blocks = new Looks(fakeRuntime); + +test('getCostumeNumberName returns 1-indexed costume number', t => { + util.target.currentCostume = 0; // This is 0-indexed. + const args = {NUMBER_NAME: 'number'}; + const number = blocks.getCostumeNumberName(args, util); + t.strictEqual(number, 1); + t.end(); +}); + +test('getCostumeNumberName can return costume name', t => { + util.target.currentCostume = 0; // This is 0-indexed. + const args = {NUMBER_NAME: 'name'}; + const number = blocks.getCostumeNumberName(args, util); + t.strictEqual(number, 'first name'); + t.end(); +}); + +test('getBackdropNumberName returns 1-indexed costume number', t => { + util.target.currentCostume = 2; // This is 0-indexed. + const args = {NUMBER_NAME: 'number'}; + const number = blocks.getBackdropNumberName(args, util); + t.strictEqual(number, 3); + t.end(); +}); + +test('getBackdropNumberName can return costume name', t => { + util.target.currentCostume = 2; // This is 0-indexed. + const args = {NUMBER_NAME: 'name'}; + const number = blocks.getBackdropNumberName(args, util); + t.strictEqual(number, 'third name'); + t.end(); +}); diff --git a/test/unit/blocks_operators.js b/test/unit/blocks_operators.js index f7befa06..d0dcbd55 100644 --- a/test/unit/blocks_operators.js +++ b/test/unit/blocks_operators.js @@ -1,175 +1,186 @@ -var test = require('tap').test; -var Operators = require('../../src/blocks/scratch3_operators'); +const test = require('tap').test; +const Operators = require('../../src/blocks/scratch3_operators'); -var blocks = new Operators(null); +const blocks = new Operators(null); -test('getPrimitives', function (t) { +test('getPrimitives', t => { t.type(blocks.getPrimitives(), 'object'); t.end(); }); -test('add', function (t) { - t.strictEqual(blocks.add({NUM1:'1', NUM2:'1'}), 2); - t.strictEqual(blocks.add({NUM1:'foo', NUM2:'bar'}), 0); +test('add', t => { + t.strictEqual(blocks.add({NUM1: '1', NUM2: '1'}), 2); + t.strictEqual(blocks.add({NUM1: 'foo', NUM2: 'bar'}), 0); t.end(); }); -test('subtract', function (t) { - t.strictEqual(blocks.subtract({NUM1:'1', NUM2:'1'}), 0); - t.strictEqual(blocks.subtract({NUM1:'foo', NUM2:'bar'}), 0); +test('subtract', t => { + t.strictEqual(blocks.subtract({NUM1: '1', NUM2: '1'}), 0); + t.strictEqual(blocks.subtract({NUM1: 'foo', NUM2: 'bar'}), 0); t.end(); }); -test('multiply', function (t) { - t.strictEqual(blocks.multiply({NUM1:'2', NUM2:'2'}), 4); - t.strictEqual(blocks.multiply({NUM1:'foo', NUM2:'bar'}), 0); +test('multiply', t => { + t.strictEqual(blocks.multiply({NUM1: '2', NUM2: '2'}), 4); + t.strictEqual(blocks.multiply({NUM1: 'foo', NUM2: 'bar'}), 0); t.end(); }); -test('divide', function (t) { - t.strictEqual(blocks.divide({NUM1:'2', NUM2:'2'}), 1); - t.strictEqual(blocks.divide({NUM1:'1', NUM2:'0'}), Infinity); // @todo - t.ok(isNaN(blocks.divide({NUM1:'foo', NUM2:'bar'}))); // @todo +test('divide', t => { + t.strictEqual(blocks.divide({NUM1: '2', NUM2: '2'}), 1); + t.strictEqual(blocks.divide({NUM1: '1', NUM2: '0'}), Infinity); // @todo + t.ok(isNaN(blocks.divide({NUM1: 'foo', NUM2: 'bar'}))); // @todo t.end(); }); -test('lt', function (t) { - t.strictEqual(blocks.lt({OPERAND1:'1', OPERAND2:'2'}), true); - t.strictEqual(blocks.lt({OPERAND1:'2', OPERAND2:'1'}), false); - t.strictEqual(blocks.lt({OPERAND1:'1', OPERAND2:'1'}), false); +test('lt', t => { + t.strictEqual(blocks.lt({OPERAND1: '1', OPERAND2: '2'}), true); + t.strictEqual(blocks.lt({OPERAND1: '2', OPERAND2: '1'}), false); + t.strictEqual(blocks.lt({OPERAND1: '1', OPERAND2: '1'}), false); t.end(); }); -test('equals', function (t) { - t.strictEqual(blocks.equals({OPERAND1:'1', OPERAND2:'2'}), false); - t.strictEqual(blocks.equals({OPERAND1:'2', OPERAND2:'1'}), false); - t.strictEqual(blocks.equals({OPERAND1:'1', OPERAND2:'1'}), true); +test('equals', t => { + t.strictEqual(blocks.equals({OPERAND1: '1', OPERAND2: '2'}), false); + t.strictEqual(blocks.equals({OPERAND1: '2', OPERAND2: '1'}), false); + t.strictEqual(blocks.equals({OPERAND1: '1', OPERAND2: '1'}), true); t.end(); }); -test('gt', function (t) { - t.strictEqual(blocks.gt({OPERAND1:'1', OPERAND2:'2'}), false); - t.strictEqual(blocks.gt({OPERAND1:'2', OPERAND2:'1'}), true); - t.strictEqual(blocks.gt({OPERAND1:'1', OPERAND2:'1'}), false); +test('gt', t => { + t.strictEqual(blocks.gt({OPERAND1: '1', OPERAND2: '2'}), false); + t.strictEqual(blocks.gt({OPERAND1: '2', OPERAND2: '1'}), true); + t.strictEqual(blocks.gt({OPERAND1: '1', OPERAND2: '1'}), false); t.end(); }); -test('and', function (t) { - t.strictEqual(blocks.and({OPERAND1:true, OPERAND2:true}), true); - t.strictEqual(blocks.and({OPERAND1:true, OPERAND2:false}), false); - t.strictEqual(blocks.and({OPERAND1:false, OPERAND2:false}), false); +test('and', t => { + t.strictEqual(blocks.and({OPERAND1: true, OPERAND2: true}), true); + t.strictEqual(blocks.and({OPERAND1: true, OPERAND2: false}), false); + t.strictEqual(blocks.and({OPERAND1: false, OPERAND2: false}), false); t.end(); }); -test('or', function (t) { - t.strictEqual(blocks.or({OPERAND1:true, OPERAND2:true}), true); - t.strictEqual(blocks.or({OPERAND1:true, OPERAND2:false}), true); - t.strictEqual(blocks.or({OPERAND1:false, OPERAND2:false}), false); +test('or', t => { + t.strictEqual(blocks.or({OPERAND1: true, OPERAND2: true}), true); + t.strictEqual(blocks.or({OPERAND1: true, OPERAND2: false}), true); + t.strictEqual(blocks.or({OPERAND1: false, OPERAND2: false}), false); t.end(); }); -test('not', function (t) { - t.strictEqual(blocks.not({OPERAND:true}), false); - t.strictEqual(blocks.not({OPERAND:false}), true); +test('not', t => { + t.strictEqual(blocks.not({OPERAND: true}), false); + t.strictEqual(blocks.not({OPERAND: false}), true); t.end(); }); -test('random', function (t) { - var min = 0; - var max = 100; - var result = blocks.random({FROM:min, TO:max}); +test('random', t => { + const min = 0; + const max = 100; + const result = blocks.random({FROM: min, TO: max}); t.ok(result >= min); t.ok(result <= max); t.end(); }); -test('random - equal', function (t) { - var min = 1; - var max = 1; - t.strictEqual(blocks.random({FROM:min, TO:max}), min); +test('random - equal', t => { + const min = 1; + const max = 1; + t.strictEqual(blocks.random({FROM: min, TO: max}), min); t.end(); }); -test('random - decimal', function (t) { - var min = 0.1; - var max = 10; - var result = blocks.random({FROM:min, TO:max}); +test('random - decimal', t => { + const min = 0.1; + const max = 10; + const result = blocks.random({FROM: min, TO: max}); t.ok(result >= min); t.ok(result <= max); t.end(); }); -test('random - int', function (t) { - var min = 0; - var max = 10; - var result = blocks.random({FROM:min, TO:max}); +test('random - int', t => { + const min = 0; + const max = 10; + const result = blocks.random({FROM: min, TO: max}); t.ok(result >= min); t.ok(result <= max); t.end(); }); -test('random - reverse', function (t) { - var min = 0; - var max = 10; - var result = blocks.random({FROM:max, TO:min}); +test('random - reverse', t => { + const min = 0; + const max = 10; + const result = blocks.random({FROM: max, TO: min}); t.ok(result >= min); t.ok(result <= max); t.end(); }); -test('join', function (t) { - t.strictEqual(blocks.join({STRING1:'foo', STRING2:'bar'}), 'foobar'); - t.strictEqual(blocks.join({STRING1:'1', STRING2:'2'}), '12'); +test('join', t => { + t.strictEqual(blocks.join({STRING1: 'foo', STRING2: 'bar'}), 'foobar'); + t.strictEqual(blocks.join({STRING1: '1', STRING2: '2'}), '12'); t.end(); }); -test('letterOf', function (t) { - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:0}), ''); - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:1}), 'f'); - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:2}), 'o'); - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:3}), 'o'); - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:4}), ''); - t.strictEqual(blocks.letterOf({STRING:'foo', LETTER:'bar'}), ''); +test('letterOf', t => { + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 0}), ''); + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 1}), 'f'); + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 2}), 'o'); + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 3}), 'o'); + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 4}), ''); + t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 'bar'}), ''); t.end(); }); -test('length', function (t) { - t.strictEqual(blocks.length({STRING:''}), 0); - t.strictEqual(blocks.length({STRING:'foo'}), 3); - t.strictEqual(blocks.length({STRING:'1'}), 1); - t.strictEqual(blocks.length({STRING:'100'}), 3); +test('length', t => { + t.strictEqual(blocks.length({STRING: ''}), 0); + t.strictEqual(blocks.length({STRING: 'foo'}), 3); + t.strictEqual(blocks.length({STRING: '1'}), 1); + t.strictEqual(blocks.length({STRING: '100'}), 3); t.end(); }); -test('mod', function (t) { - t.strictEqual(blocks.mod({NUM1:1, NUM2:1}), 0); - t.strictEqual(blocks.mod({NUM1:3, NUM2:6}), 3); - t.strictEqual(blocks.mod({NUM1:-3, NUM2:6}), 3); +test('contains', t => { + t.strictEqual(blocks.contains({STRING1: 'hello world', STRING2: 'hello'}), true); + t.strictEqual(blocks.contains({STRING1: 'foo', STRING2: 'bar'}), false); + t.strictEqual(blocks.contains({STRING1: 'HeLLo world', STRING2: 'hello'}), true); t.end(); }); -test('round', function (t) { - t.strictEqual(blocks.round({NUM:1}), 1); - t.strictEqual(blocks.round({NUM:1.1}), 1); - t.strictEqual(blocks.round({NUM:1.5}), 2); +test('mod', t => { + t.strictEqual(blocks.mod({NUM1: 1, NUM2: 1}), 0); + t.strictEqual(blocks.mod({NUM1: 3, NUM2: 6}), 3); + t.strictEqual(blocks.mod({NUM1: -3, NUM2: 6}), 3); t.end(); }); -test('mathop', function (t) { - t.strictEqual(blocks.mathop({OPERATOR:'abs', NUM:-1}), 1); - t.strictEqual(blocks.mathop({OPERATOR:'floor', NUM:1.5}), 1); - t.strictEqual(blocks.mathop({OPERATOR:'ceiling', NUM:0.1}), 1); - t.strictEqual(blocks.mathop({OPERATOR:'sqrt', NUM:1}), 1); - t.strictEqual(blocks.mathop({OPERATOR:'sin', NUM:1}), 0.01745240643728351); - t.strictEqual(blocks.mathop({OPERATOR:'cos', NUM:1}), 0.9998476951563913); - t.strictEqual(blocks.mathop({OPERATOR:'tan', NUM:1}), 0.017455064928217585); - t.strictEqual(blocks.mathop({OPERATOR:'asin', NUM:1}), 90); - t.strictEqual(blocks.mathop({OPERATOR:'acos', NUM:1}), 0); - t.strictEqual(blocks.mathop({OPERATOR:'atan', NUM:1}), 45); - t.strictEqual(blocks.mathop({OPERATOR:'ln', NUM:1}), 0); - t.strictEqual(blocks.mathop({OPERATOR:'log', NUM:1}), 0); - t.strictEqual(blocks.mathop({OPERATOR:'e ^', NUM:1}), 2.718281828459045); - t.strictEqual(blocks.mathop({OPERATOR:'10 ^', NUM:1}), 10); - t.strictEqual(blocks.mathop({OPERATOR:'undefined', NUM:1}), 0); +test('round', t => { + t.strictEqual(blocks.round({NUM: 1}), 1); + t.strictEqual(blocks.round({NUM: 1.1}), 1); + t.strictEqual(blocks.round({NUM: 1.5}), 2); + t.end(); +}); + +test('mathop', t => { + t.strictEqual(blocks.mathop({OPERATOR: 'abs', NUM: -1}), 1); + t.strictEqual(blocks.mathop({OPERATOR: 'floor', NUM: 1.5}), 1); + t.strictEqual(blocks.mathop({OPERATOR: 'ceiling', NUM: 0.1}), 1); + t.strictEqual(blocks.mathop({OPERATOR: 'sqrt', NUM: 1}), 1); + t.strictEqual(blocks.mathop({OPERATOR: 'sin', NUM: 1}), 0.0174524064); + t.strictEqual(blocks.mathop({OPERATOR: 'sin', NUM: 90}), 1); + t.strictEqual(blocks.mathop({OPERATOR: 'cos', NUM: 1}), 0.9998476952); + t.strictEqual(blocks.mathop({OPERATOR: 'cos', NUM: 180}), -1); + t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 1}), 0.0174550649); + t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 90}), Infinity); + t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 180}), 0); + t.strictEqual(blocks.mathop({OPERATOR: 'asin', NUM: 1}), 90); + t.strictEqual(blocks.mathop({OPERATOR: 'acos', NUM: 1}), 0); + t.strictEqual(blocks.mathop({OPERATOR: 'atan', NUM: 1}), 45); + t.strictEqual(blocks.mathop({OPERATOR: 'ln', NUM: 1}), 0); + t.strictEqual(blocks.mathop({OPERATOR: 'log', NUM: 1}), 0); + t.strictEqual(blocks.mathop({OPERATOR: 'e ^', NUM: 1}), 2.718281828459045); + t.strictEqual(blocks.mathop({OPERATOR: '10 ^', NUM: 1}), 10); + t.strictEqual(blocks.mathop({OPERATOR: 'undefined', NUM: 1}), 0); t.end(); }); diff --git a/test/unit/blocks_procedures.js b/test/unit/blocks_procedures.js new file mode 100644 index 00000000..0285b23d --- /dev/null +++ b/test/unit/blocks_procedures.js @@ -0,0 +1,28 @@ +const test = require('tap').test; +const Procedures = require('../../src/blocks/scratch3_procedures'); + +const blocks = new Procedures(null); + +test('getPrimitives', t => { + t.type(blocks.getPrimitives(), 'object'); + t.end(); +}); + +// Originally inspired by https://github.com/LLK/scratch-gui/issues/809 +test('calling a custom block with no definition does not throw', t => { + const args = { + mutation: { + proccode: 'undefined proc' + } + }; + const util = { + getProcedureParamNamesAndIds: () => null, + stackFrame: { + executed: false + } + }; + t.doesNotThrow(() => { + blocks.call(args, util); + }); + t.end(); +}); diff --git a/test/unit/blocks_sensing.js b/test/unit/blocks_sensing.js new file mode 100644 index 00000000..33b48446 --- /dev/null +++ b/test/unit/blocks_sensing.js @@ -0,0 +1,85 @@ +const test = require('tap').test; +const Sensing = require('../../src/blocks/scratch3_sensing'); +const Runtime = require('../../src/engine/runtime'); +const Sprite = require('../../src/sprites/sprite'); +const RenderedTarget = require('../../src/sprites/rendered-target'); + +test('getPrimitives', t => { + const rt = new Runtime(); + const s = new Sensing(rt); + t.type(s.getPrimitives(), 'object'); + t.end(); +}); + +test('ask and answer with a hidden target', t => { + const rt = new Runtime(); + const s = new Sensing(rt); + const util = {target: {visible: false}}; + + const expectedQuestion = 'a question'; + const expectedAnswer = 'the answer'; + + // Test is written out of order because of promises, follow the (#) comments. + rt.addListener('QUESTION', question => { + // (2) Assert the question is correct, then emit the answer + t.strictEqual(question, expectedQuestion); + rt.emit('ANSWER', expectedAnswer); + }); + + // (1) Emit the question. + const promise = s.askAndWait({QUESTION: expectedQuestion}, util); + + // (3) Ask block resolves after the answer is emitted. + promise.then(() => { + t.strictEqual(s.getAnswer(), expectedAnswer); + t.end(); + }); +}); + +test('ask and answer with a visible target', t => { + const rt = new Runtime(); + const s = new Sensing(rt); + const util = {target: {visible: true}}; + + const expectedQuestion = 'a question'; + const expectedAnswer = 'the answer'; + + rt.removeAllListeners('SAY'); // Prevent say blocks from executing + + rt.addListener('SAY', (target, type, question) => { + // Should emit SAY with the question + t.strictEqual(question, expectedQuestion); + }); + + rt.addListener('QUESTION', question => { + // Question should be blank for a visible target + t.strictEqual(question, ''); + + // Remove the say listener and add a new one to assert bubble is cleared + // by setting say to empty string after answer is received. + rt.removeAllListeners('SAY'); + rt.addListener('SAY', (target, type, text) => { + t.strictEqual(text, ''); + t.end(); + }); + rt.emit('ANSWER', expectedAnswer); + }); + + s.askAndWait({QUESTION: expectedQuestion}, util); +}); + +test('set drag mode', t => { + const runtime = new Runtime(); + runtime.requestTargetsUpdate = () => {}; // noop for testing + const sensing = new Sensing(runtime); + const s = new Sprite(); + const rt = new RenderedTarget(s, runtime); + + sensing.setDragMode({DRAG_MODE: 'not draggable'}, {target: rt}); + t.strictEqual(rt.draggable, false); + + sensing.setDragMode({DRAG_MODE: 'draggable'}, {target: rt}); + t.strictEqual(rt.draggable, true); + + t.end(); +}); diff --git a/test/unit/blocks_sounds.js b/test/unit/blocks_sounds.js new file mode 100644 index 00000000..9a44e7f9 --- /dev/null +++ b/test/unit/blocks_sounds.js @@ -0,0 +1,73 @@ +const test = require('tap').test; +const Sound = require('../../src/blocks/scratch3_sound'); +let playedSound; + +const blocks = new Sound(); +const util = { + target: { + sprite: { + sounds: [ + {name: 'first name', soundId: 'first soundId'}, + {name: 'second name', soundId: 'second soundId'}, + {name: 'third name', soundId: 'third soundId'}, + {name: '6', soundId: 'fourth soundId'} + ] + }, + audioPlayer: { + playSound: soundId => (playedSound = soundId) + } + } +}; + +test('playSound with a name string works', t => { + const args = {SOUND_MENU: 'second name'}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'second soundId'); + t.end(); +}); + +test('playSound with a number string works 1-indexed', t => { + let args = {SOUND_MENU: '5'}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'first soundId'); + + args = {SOUND_MENU: '1'}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'first soundId'); + + args = {SOUND_MENU: '0'}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'fourth soundId'); + t.end(); +}); + +test('playSound with a number works 1-indexed', t => { + let args = {SOUND_MENU: 5}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'first soundId'); + + args = {SOUND_MENU: 1}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'first soundId'); + + args = {SOUND_MENU: 0}; + blocks.playSound(args, util); + t.strictEqual(playedSound, 'fourth soundId'); + t.end(); +}); + +test('playSound prioritizes sound index if given a number', t => { + const args = {SOUND_MENU: 6}; + blocks.playSound(args, util); + // Ignore the sound named '6', wrapClamp to the second instead + t.strictEqual(playedSound, 'second soundId'); + t.end(); +}); + +test('playSound prioritizes sound name if given a string', t => { + const args = {SOUND_MENU: '6'}; + blocks.playSound(args, util); + // Use the sound named '6', which is the fourth + t.strictEqual(playedSound, 'fourth soundId'); + t.end(); +}); diff --git a/test/unit/dispatch.js b/test/unit/dispatch.js new file mode 100644 index 00000000..fdcbdb3e --- /dev/null +++ b/test/unit/dispatch.js @@ -0,0 +1,63 @@ +const DispatchTestService = require('../fixtures/dispatch-test-service'); +const Worker = require('tiny-worker'); + +const dispatch = require('../../src/dispatch/central-dispatch'); +const path = require('path'); +const test = require('tap').test; + + +// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. +dispatch.workerClass = Worker; + +const runServiceTest = function (serviceName, t) { + const promises = []; + + promises.push(dispatch.call(serviceName, 'returnFortyTwo') + .then( + x => t.equal(x, 42), + e => t.fail(e) + )); + + promises.push(dispatch.call(serviceName, 'doubleArgument', 9) + .then( + x => t.equal(x, 18), + e => t.fail(e) + )); + + promises.push(dispatch.call(serviceName, 'doubleArgument', 123) + .then( + x => t.equal(x, 246), + e => t.fail(e) + )); + + // I tried using `t.rejects` here but ran into https://github.com/tapjs/node-tap/issues/384 + promises.push(dispatch.call(serviceName, 'throwException') + .then( + () => t.fail('exception was not propagated as expected'), + () => t.pass('exception was propagated as expected') + )); + + return Promise.all(promises); +}; + +test('local', t => { + dispatch.setService('LocalDispatchTest', new DispatchTestService()) + .catch(e => t.fail(e)); + + return runServiceTest('LocalDispatchTest', t); +}); + +test('remote', t => { + const fixturesDir = path.resolve(__dirname, '../fixtures'); + const worker = new Worker('./test/fixtures/dispatch-test-worker-shim.js', null, {cwd: fixturesDir}); + dispatch.addWorker(worker); + + const waitForWorker = new Promise(resolve => { + dispatch.setService('test', {onWorkerReady: resolve}) + .catch(e => t.fail(e)); + }); + + return waitForWorker + .then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e)) + .then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e)); +}); diff --git a/test/unit/engine_adapter.js b/test/unit/engine_adapter.js index 19289dac..4255fb8f 100644 --- a/test/unit/engine_adapter.js +++ b/test/unit/engine_adapter.js @@ -1,22 +1,22 @@ -var test = require('tap').test; -var adapter = require('../../src/engine/adapter'); -var events = require('../fixtures/events.json'); +const test = require('tap').test; +const adapter = require('../../src/engine/adapter'); +const events = require('../fixtures/events.json'); -test('spec', function (t) { +test('spec', t => { t.type(adapter, 'function'); t.end(); }); -test('invalid inputs', function(t) { - var nothing = adapter('not an object'); +test('invalid inputs', t => { + let nothing = adapter('not an object'); t.type(nothing, 'undefined'); - nothing = adapter({noxmlproperty:true}); + nothing = adapter({noxmlproperty: true}); t.type(nothing, 'undefined'); t.end(); }); -test('create event', function (t) { - var result = adapter(events.create); +test('create event', t => { + const result = adapter(events.create); t.ok(Array.isArray(result)); t.equal(result.length, 2); @@ -26,7 +26,7 @@ test('create event', function (t) { t.type(result[0].opcode, 'string'); t.type(result[0].fields, 'object'); t.type(result[0].inputs, 'object'); - t.type(result[0].inputs['DURATION'], 'object'); + t.type(result[0].inputs.DURATION, 'object'); t.type(result[0].topLevel, 'boolean'); t.equal(result[0].topLevel, true); @@ -35,33 +35,33 @@ test('create event', function (t) { t.type(result[1].opcode, 'string'); t.type(result[1].fields, 'object'); t.type(result[1].inputs, 'object'); - t.type(result[1].fields['NUM'], 'object'); - t.type(result[1].fields['NUM'].value, '10'); + t.type(result[1].fields.NUM, 'object'); + t.type(result[1].fields.NUM.value, '10'); t.type(result[1].topLevel, 'boolean'); t.equal(result[1].topLevel, false); t.end(); }); -test('create with branch', function (t) { - var result = adapter(events.createbranch); +test('create with branch', t => { + const result = adapter(events.createbranch); // Outer block t.type(result[0].id, 'string'); t.type(result[0].opcode, 'string'); t.type(result[0].fields, 'object'); t.type(result[0].inputs, 'object'); - t.type(result[0].inputs['SUBSTACK'], 'object'); + t.type(result[0].inputs.SUBSTACK, 'object'); t.type(result[0].topLevel, 'boolean'); t.equal(result[0].topLevel, true); // In branch - var branchBlockId = result[0].inputs['SUBSTACK']['block']; - var branchShadowId = result[0].inputs['SUBSTACK']['shadow']; + const branchBlockId = result[0].inputs.SUBSTACK.block; + const branchShadowId = result[0].inputs.SUBSTACK.shadow; t.type(branchBlockId, 'string'); t.equal(branchShadowId, null); // Find actual branch block - var branchBlock = null; - for (var i = 0; i < result.length; i++) { - if (result[i].id == branchBlockId) { + let branchBlock = null; + for (let i = 0; i < result.length; i++) { + if (result[i].id === branchBlockId) { branchBlock = result[i]; } } @@ -69,34 +69,34 @@ test('create with branch', function (t) { t.end(); }); -test('create with two branches', function (t) { - var result = adapter(events.createtwobranches); +test('create with two branches', t => { + const result = adapter(events.createtwobranches); // Outer block t.type(result[0].id, 'string'); t.type(result[0].opcode, 'string'); t.type(result[0].fields, 'object'); t.type(result[0].inputs, 'object'); - t.type(result[0].inputs['SUBSTACK'], 'object'); - t.type(result[0].inputs['SUBSTACK2'], 'object'); + t.type(result[0].inputs.SUBSTACK, 'object'); + t.type(result[0].inputs.SUBSTACK2, 'object'); t.type(result[0].topLevel, 'boolean'); t.equal(result[0].topLevel, true); // In branchs - var firstBranchBlockId = result[0].inputs['SUBSTACK']['block']; - var secondBranchBlockId = result[0].inputs['SUBSTACK2']['block']; + const firstBranchBlockId = result[0].inputs.SUBSTACK.block; + const secondBranchBlockId = result[0].inputs.SUBSTACK2.block; t.type(firstBranchBlockId, 'string'); t.type(secondBranchBlockId, 'string'); - var firstBranchShadowBlockId = result[0].inputs['SUBSTACK']['shadow']; - var secondBranchShadowBlockId = result[0].inputs['SUBSTACK2']['shadow']; + const firstBranchShadowBlockId = result[0].inputs.SUBSTACK.shadow; + const secondBranchShadowBlockId = result[0].inputs.SUBSTACK2.shadow; t.equal(firstBranchShadowBlockId, null); t.equal(secondBranchShadowBlockId, null); // Find actual branch blocks - var firstBranchBlock = null; - var secondBranchBlock = null; - for (var i = 0; i < result.length; i++) { - if (result[i].id == firstBranchBlockId) { + let firstBranchBlock = null; + let secondBranchBlock = null; + for (let i = 0; i < result.length; i++) { + if (result[i].id === firstBranchBlockId) { firstBranchBlock = result[i]; } - if (result[i].id == secondBranchBlockId) { + if (result[i].id === secondBranchBlockId) { secondBranchBlock = result[i]; } } @@ -105,8 +105,8 @@ test('create with two branches', function (t) { t.end(); }); -test('create with top-level shadow', function (t) { - var result = adapter(events.createtoplevelshadow); +test('create with top-level shadow', t => { + const result = adapter(events.createtoplevelshadow); t.ok(Array.isArray(result)); t.equal(result.length, 1); @@ -120,8 +120,8 @@ test('create with top-level shadow', function (t) { t.end(); }); -test('create with next connection', function (t) { - var result = adapter(events.createwithnext); +test('create with next connection', t => { + const result = adapter(events.createwithnext); t.ok(Array.isArray(result)); t.equal(result.length, 2); @@ -148,21 +148,39 @@ test('create with next connection', function (t) { t.end(); }); -test('create with obscured shadow', function (t) { - var result = adapter(events.createobscuredshadow); +test('create with obscured shadow', t => { + const result = adapter(events.createobscuredshadow); t.ok(Array.isArray(result)); t.equal(result.length, 4); t.end(); }); -test('create with invalid block xml', function (t) { +test('create variable with entity in name', t => { + const result = adapter(events.createvariablewithentity); + + t.ok(Array.isArray(result)); + t.equal(result.length, 1); + + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].fields.VARIABLE, 'object'); + t.type(result[0].fields.VARIABLE.value, 'string'); + t.equal(result[0].fields.VARIABLE.value, 'this & that'); + t.type(result[0].inputs, 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + t.end(); +}); + +test('create with invalid block xml', t => { // Entirely invalid block XML - var result = adapter(events.createinvalid); + const result = adapter(events.createinvalid); t.ok(Array.isArray(result)); t.equal(result.length, 0); // Invalid grandchild tag - var result2 = adapter(events.createinvalidgrandchild); + const result2 = adapter(events.createinvalidgrandchild); t.ok(Array.isArray(result2)); t.equal(result2.length, 1); t.type(result2[0].id, 'string'); @@ -172,15 +190,15 @@ test('create with invalid block xml', function (t) { t.end(); }); -test('create with invalid xml', function (t) { - var result = adapter(events.createbadxml); +test('create with invalid xml', t => { + const result = adapter(events.createbadxml); t.ok(Array.isArray(result)); t.equal(result.length, 0); t.end(); }); -test('create with empty field', function (t) { - var result = adapter(events.createemptyfield); +test('create with empty field', t => { + const result = adapter(events.createemptyfield); t.ok(Array.isArray(result)); t.equal(result.length, 3); t.end(); diff --git a/test/unit/engine_blocks.js b/test/unit/engine_blocks.js index 2c9f8f13..4353fe47 100644 --- a/test/unit/engine_blocks.js +++ b/test/unit/engine_blocks.js @@ -1,8 +1,8 @@ -var test = require('tap').test; -var Blocks = require('../../src/engine/blocks'); +const test = require('tap').test; +const Blocks = require('../../src/engine/blocks'); -test('spec', function (t) { - var b = new Blocks(); +test('spec', t => { + const b = new Blocks(); t.type(Blocks, 'function'); t.type(b, 'object'); @@ -27,8 +27,8 @@ test('spec', function (t) { }); // Getter tests -test('getBlock', function (t) { - var b = new Blocks(); +test('getBlock', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -37,16 +37,16 @@ test('getBlock', function (t) { inputs: {}, topLevel: true }); - var block = b.getBlock('foo'); + const block = b.getBlock('foo'); t.type(block, 'object'); - var notBlock = b.getBlock('?'); + const notBlock = b.getBlock('?'); t.type(notBlock, 'undefined'); t.end(); }); -test('getScripts', function (t) { - var b = new Blocks(); - var scripts = b.getScripts(); +test('getScripts', t => { + const b = new Blocks(); + let scripts = b.getScripts(); t.type(scripts, 'object'); t.equals(scripts.length, 0); // Create two top-level blocks and one not. @@ -85,8 +85,8 @@ test('getScripts', function (t) { }); -test('getNextBlock', function (t) { - var b = new Blocks(); +test('getNextBlock', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -96,7 +96,7 @@ test('getNextBlock', function (t) { topLevel: true }); - var next = b.getNextBlock('foo'); + let next = b.getNextBlock('foo'); t.equals(next, null); // Add a block with "foo" as its next. @@ -113,14 +113,14 @@ test('getNextBlock', function (t) { t.equals(next, 'foo'); // Block that doesn't exist. - var noBlock = b.getNextBlock('?'); + const noBlock = b.getNextBlock('?'); t.equals(noBlock, null); t.end(); }); -test('getBranch', function (t) { - var b = new Blocks(); +test('getBranch', t => { + const b = new Blocks(); // Single branch b.createBlock({ id: 'foo', @@ -145,17 +145,17 @@ test('getBranch', function (t) { topLevel: false }); - var branch = b.getBranch('foo'); + const branch = b.getBranch('foo'); t.equals(branch, 'foo2'); - var notBranch = b.getBranch('?'); + const notBranch = b.getBranch('?'); t.equals(notBranch, null); t.end(); }); -test('getBranch2', function (t) { - var b = new Blocks(); +test('getBranch2', t => { + const b = new Blocks(); // Second branch b.createBlock({ id: 'foo', @@ -193,16 +193,16 @@ test('getBranch2', function (t) { topLevel: false }); - var branch1 = b.getBranch('foo', 1); - var branch2 = b.getBranch('foo', 2); + const branch1 = b.getBranch('foo', 1); + const branch2 = b.getBranch('foo', 2); t.equals(branch1, 'foo2'); t.equals(branch2, 'foo3'); t.end(); }); -test('getBranch with none', function (t) { - var b = new Blocks(); +test('getBranch with none', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -211,31 +211,33 @@ test('getBranch with none', function (t) { inputs: {}, topLevel: true }); - var noBranch = b.getBranch('foo'); + const noBranch = b.getBranch('foo'); t.equals(noBranch, null); t.end(); }); -test('getOpcode', function (t) { - var b = new Blocks(); - b.createBlock({ +test('getOpcode', t => { + const b = new Blocks(); + const block = { id: 'foo', opcode: 'TEST_BLOCK', next: null, fields: {}, inputs: {}, topLevel: true - }); - var opcode = b.getOpcode('foo'); + }; + b.createBlock(block); + const opcode = b.getOpcode(block); t.equals(opcode, 'TEST_BLOCK'); - var notOpcode = b.getOpcode('?'); - t.equals(notOpcode, null); + const undefinedBlock = b.getBlock('?'); + const undefinedOpcode = b.getOpcode(undefinedBlock); + t.equals(undefinedOpcode, null); t.end(); }); // Block events tests -test('create', function (t) { - var b = new Blocks(); +test('create', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -245,14 +247,14 @@ test('create', function (t) { topLevel: true }); - t.type(b._blocks['foo'], 'object'); - t.equal(b._blocks['foo'].opcode, 'TEST_BLOCK'); + t.type(b._blocks.foo, 'object'); + t.equal(b._blocks.foo.opcode, 'TEST_BLOCK'); t.notEqual(b._scripts.indexOf('foo'), -1); t.end(); }); -test('move', function (t) { - var b = new Blocks(); +test('move', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -277,7 +279,7 @@ test('move', function (t) { }); t.equal(b._scripts.length, 1); t.equal(Object.keys(b._blocks).length, 2); - t.equal(b._blocks['foo'].next, 'bar'); + t.equal(b._blocks.foo.next, 'bar'); // Detach 'bar' from 'foo' b.moveBlock({ @@ -286,13 +288,13 @@ test('move', function (t) { }); t.equal(b._scripts.length, 2); t.equal(Object.keys(b._blocks).length, 2); - t.equal(b._blocks['foo'].next, null); + t.equal(b._blocks.foo.next, null); t.end(); }); -test('move into empty', function (t) { - var b = new Blocks(); +test('move into empty', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -314,19 +316,19 @@ test('move into empty', function (t) { newInput: 'fooInput', newParent: 'foo' }); - t.equal(b._blocks['foo'].inputs['fooInput'].block, 'bar'); + t.equal(b._blocks.foo.inputs.fooInput.block, 'bar'); t.end(); }); -test('move no obscure shadow', function (t) { - var b = new Blocks(); +test('move no obscure shadow', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', next: null, fields: {}, inputs: { - 'fooInput': { + fooInput: { name: 'fooInput', block: 'x', shadow: 'y' @@ -347,13 +349,13 @@ test('move no obscure shadow', function (t) { newInput: 'fooInput', newParent: 'foo' }); - t.equal(b._blocks['foo'].inputs['fooInput'].block, 'bar'); - t.equal(b._blocks['foo'].inputs['fooInput'].shadow, 'y'); + t.equal(b._blocks.foo.inputs.fooInput.block, 'bar'); + t.equal(b._blocks.foo.inputs.fooInput.shadow, 'y'); t.end(); }); -test('change', function (t) { - var b = new Blocks(); +test('change', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -369,7 +371,7 @@ test('change', function (t) { }); // Test that the field is updated - t.equal(b._blocks['foo'].fields.someField.value, 'initial-value'); + t.equal(b._blocks.foo.fields.someField.value, 'initial-value'); b.changeBlock({ element: 'field', @@ -378,7 +380,7 @@ test('change', function (t) { value: 'final-value' }); - t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + t.equal(b._blocks.foo.fields.someField.value, 'final-value'); // Invalid cases // No `element` @@ -387,7 +389,7 @@ test('change', function (t) { name: 'someField', value: 'invalid-value' }); - t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + t.equal(b._blocks.foo.fields.someField.value, 'final-value'); // No block ID b.changeBlock({ @@ -395,7 +397,7 @@ test('change', function (t) { name: 'someField', value: 'invalid-value' }); - t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + t.equal(b._blocks.foo.fields.someField.value, 'final-value'); // No such field b.changeBlock({ @@ -404,13 +406,13 @@ test('change', function (t) { name: 'someWrongField', value: 'final-value' }); - t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + t.equal(b._blocks.foo.fields.someField.value, 'final-value'); t.end(); }); -test('delete', function (t) { - var b = new Blocks(); +test('delete', t => { + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -419,19 +421,17 @@ test('delete', function (t) { inputs: {}, topLevel: true }); - b.deleteBlock({ - id: 'foo' - }); + b.deleteBlock('foo'); - t.type(b._blocks['foo'], 'undefined'); + t.type(b._blocks.foo, 'undefined'); t.equal(b._scripts.indexOf('foo'), -1); t.end(); }); -test('delete chain', function (t) { +test('delete chain', t => { // Create a chain of connected blocks and delete the top one. // All of them should be deleted. - var b = new Blocks(); + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -456,22 +456,20 @@ test('delete chain', function (t) { inputs: {}, topLevel: false }); - b.deleteBlock({ - id: 'foo' - }); - t.type(b._blocks['foo'], 'undefined'); - t.type(b._blocks['foo2'], 'undefined'); - t.type(b._blocks['foo3'], 'undefined'); + b.deleteBlock('foo'); + t.type(b._blocks.foo, 'undefined'); + t.type(b._blocks.foo2, 'undefined'); + t.type(b._blocks.foo3, 'undefined'); t.equal(b._scripts.indexOf('foo'), -1); t.equal(Object.keys(b._blocks).length, 0); t.equal(b._scripts.length, 0); t.end(); }); -test('delete inputs', function (t) { +test('delete inputs', t => { // Create a block with two inputs, one of which has its own input. // Delete the block - all of them should be deleted. - var b = new Blocks(); + const b = new Blocks(); b.createBlock({ id: 'foo', opcode: 'TEST_BLOCK', @@ -529,14 +527,12 @@ test('delete inputs', function (t) { inputs: {}, topLevel: false }); - b.deleteBlock({ - id: 'foo' - }); - t.type(b._blocks['foo'], 'undefined'); - t.type(b._blocks['foo2'], 'undefined'); - t.type(b._blocks['foo3'], 'undefined'); - t.type(b._blocks['foo4'], 'undefined'); - t.type(b._blocks['foo5'], 'undefined'); + b.deleteBlock('foo'); + t.type(b._blocks.foo, 'undefined'); + t.type(b._blocks.foo2, 'undefined'); + t.type(b._blocks.foo3, 'undefined'); + t.type(b._blocks.foo4, 'undefined'); + t.type(b._blocks.foo5, 'undefined'); t.equal(b._scripts.indexOf('foo'), -1); t.equal(Object.keys(b._blocks).length, 0); t.equal(b._scripts.length, 0); diff --git a/test/unit/engine_runtime.js b/test/unit/engine_runtime.js index 40757c05..458f3884 100644 --- a/test/unit/engine_runtime.js +++ b/test/unit/engine_runtime.js @@ -1,8 +1,10 @@ -var test = require('tap').test; -var Runtime = require('../../src/engine/runtime'); +const test = require('tap').test; +const Runtime = require('../../src/engine/runtime'); +const MonitorRecord = require('../../src/engine/monitor-record'); +const {Map} = require('immutable'); -test('spec', function (t) { - var r = new Runtime(); +test('spec', t => { + const r = new Runtime(); t.type(Runtime, 'function'); t.type(r, 'object'); @@ -10,3 +12,58 @@ test('spec', function (t) { t.end(); }); + +test('monitorStateEquals', t => { + const r = new Runtime(); + const id = 'xklj4#!'; + const prevMonitorState = MonitorRecord({ + id, + opcode: 'turtle whereabouts', + value: '25' + }); + const newMonitorDelta = Map({ + id, + value: String(25) + }); + r.requestAddMonitor(prevMonitorState); + r.requestUpdateMonitor(newMonitorDelta); + + t.equals(true, prevMonitorState === r._monitorState.get(id)); + t.equals(String(25), r._monitorState.get(id).get('value')); + t.end(); +}); + +test('monitorStateDoesNotEqual', t => { + const r = new Runtime(); + const id = 'xklj4#!'; + const params = {seven: 7}; + const prevMonitorState = MonitorRecord({ + id, + opcode: 'turtle whereabouts', + value: '25' + }); + + // Value change + let newMonitorDelta = Map({ + id, + value: String(24) + }); + r.requestAddMonitor(prevMonitorState); + r.requestUpdateMonitor(newMonitorDelta); + + t.equals(false, prevMonitorState.equals(r._monitorState.get(id))); + t.equals(String(24), r._monitorState.get(id).get('value')); + + // Prop change + newMonitorDelta = Map({ + id: 'xklj4#!', + params: params + }); + r.requestUpdateMonitor(newMonitorDelta); + + t.equals(false, prevMonitorState.equals(r._monitorState.get(id))); + t.equals(String(24), r._monitorState.get(id).value); + t.equals(params, r._monitorState.get(id).params); + + t.end(); +}); diff --git a/test/unit/engine_sequencer.js b/test/unit/engine_sequencer.js index 23ec4c13..751e9bb6 100644 --- a/test/unit/engine_sequencer.js +++ b/test/unit/engine_sequencer.js @@ -1,8 +1,188 @@ -var test = require('tap').test; -var Sequencer = require('../../src/engine/sequencer'); +const test = require('tap').test; +const Sequencer = require('../../src/engine/sequencer'); +const Runtime = require('../../src/engine/runtime'); +const Thread = require('../../src/engine/thread'); +const RenderedTarget = require('../../src/sprites/rendered-target'); +const Sprite = require('../../src/sprites/sprite'); -test('spec', function (t) { +test('spec', t => { t.type(Sequencer, 'function'); - // @todo + + const r = new Runtime(); + const s = new Sequencer(r); + + t.type(s, 'object'); + t.ok(s instanceof Sequencer); + + t.type(s.stepThreads, 'function'); + t.type(s.stepThread, 'function'); + t.type(s.stepToBranch, 'function'); + t.type(s.stepToProcedure, 'function'); + t.type(s.retireThread, 'function'); + + t.end(); +}); + +const randomString = function () { + const top = Math.random().toString(36); + return top.substring(7); +}; + +const generateBlock = function (id) { + const block = {fields: Object, + id: id, + inputs: {}, + STEPS: Object, + block: 'fakeBlock', + name: 'fakeName', + next: null, + opcode: 'procedures_definition', + mutation: {proccode: 'fakeCode'}, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + return block; +}; + +const generateBlockInput = function (id, next, inp) { + const block = {fields: Object, + id: id, + inputs: {SUBSTACK: {block: inp, name: 'SUBSTACK'}}, + STEPS: Object, + block: 'fakeBlock', + name: 'fakeName', + next: next, + opcode: 'procedures_definition', + mutation: {proccode: 'fakeCode'}, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + return block; +}; + +const generateThread = function (runtime) { + const s = new Sprite(); + const rt = new RenderedTarget(s, runtime); + const th = new Thread(randomString()); + + let next = randomString(); + let inp = randomString(); + let name = th.topBlock; + + rt.blocks.createBlock(generateBlockInput(name, next, inp)); + th.pushStack(name); + rt.blocks.createBlock(generateBlock(inp)); + + for (let i = 0; i < 10; i++) { + name = next; + next = randomString(); + inp = randomString(); + + rt.blocks.createBlock(generateBlockInput(name, next, inp)); + th.pushStack(name); + rt.blocks.createBlock(generateBlock(inp)); + } + rt.blocks.createBlock(generateBlock(next)); + th.pushStack(next); + th.target = rt; + + runtime.threads.push(th); + + return th; +}; + +test('stepThread', t => { + const r = new Runtime(); + const s = new Sequencer(r); + let th = generateThread(r); + t.notEquals(th.status, Thread.STATUS_DONE); + s.stepThread(th); + t.strictEquals(th.status, Thread.STATUS_DONE); + th = generateThread(r); + th.status = Thread.STATUS_YIELD; + s.stepThread(th); + t.notEquals(th.status, Thread.STATUS_DONE); + th.status = Thread.STATUS_PROMISE_WAIT; + s.stepThread(th); + t.notEquals(th.status, Thread.STATUS_DONE); + + t.end(); +}); + +test('stepToBranch', t => { + const r = new Runtime(); + const s = new Sequencer(r); + const th = generateThread(r); + s.stepToBranch(th, 2, false); + t.strictEquals(th.peekStack(), null); + th.popStack(); + s.stepToBranch(th, 1, false); + t.strictEquals(th.peekStack(), null); + th.popStack(); + th.popStack(); + s.stepToBranch(th, 1, false); + t.notEquals(th.peekStack(), null); + + t.end(); +}); + +test('retireThread', t => { + const r = new Runtime(); + const s = new Sequencer(r); + const th = generateThread(r); + t.strictEquals(th.stack.length, 12); + s.retireThread(th); + t.strictEquals(th.stack.length, 0); + t.strictEquals(th.status, Thread.STATUS_DONE); + + t.end(); +}); + +test('stepToProcedure', t => { + const r = new Runtime(); + const s = new Sequencer(r); + const th = generateThread(r); + let expectedBlock = th.peekStack(); + s.stepToProcedure(th, ''); + t.strictEquals(th.peekStack(), expectedBlock); + s.stepToProcedure(th, 'faceCode'); + t.strictEquals(th.peekStack(), expectedBlock); + + th.target.blocks.createBlock({ + id: 'internalId', + opcode: 'procedures_prototype', + mutation: { + proccode: 'othercode' + } + }); + expectedBlock = th.stack[th.stack.length - 4]; + th.target.blocks.getBlock(expectedBlock).inputs.custom_block = { + type: 'custom_block', + block: 'internalId' + }; + s.stepToProcedure(th, 'othercode'); + t.strictEquals(th.peekStack(), expectedBlock); + + + t.end(); +}); + +test('stepThreads', t => { + const r = new Runtime(); + r.currentStepTime = Infinity; + const s = new Sequencer(r); + t.strictEquals(s.stepThreads().length, 0); + generateThread(r); + t.strictEquals(r.threads.length, 1); + t.strictEquals(s.stepThreads().length, 0); + r.threads[0].status = Thread.STATUS_RUNNING; + t.strictEquals(s.stepThreads().length, 1); + t.end(); }); diff --git a/test/unit/engine_target.js b/test/unit/engine_target.js new file mode 100644 index 00000000..30ff5eb8 --- /dev/null +++ b/test/unit/engine_target.js @@ -0,0 +1,191 @@ +const test = require('tap').test; +const Target = require('../../src/engine/target'); +const Variable = require('../../src/engine/variable'); + +test('spec', t => { + const target = new Target(); + + t.type(Target, 'function'); + t.type(target, 'object'); + t.ok(target instanceof Target); + + t.type(target.id, 'string'); + t.type(target.blocks, 'object'); + t.type(target.variables, 'object'); + t.type(target.lists, 'object'); + t.type(target._customState, 'object'); + + t.type(target.createVariable, 'function'); + t.type(target.renameVariable, 'function'); + + t.end(); +}); + +// Create Variable tests. +test('createVariable', t => { + const target = new Target(); + target.createVariable('foo', 'bar', Variable.SCALAR_TYPE); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 1); + const variable = variables[Object.keys(variables)[0]]; + t.equal(variable.id, 'foo'); + t.equal(variable.name, 'bar'); + t.equal(variable.type, Variable.SCALAR_TYPE); + t.equal(variable.value, 0); + t.equal(variable.isCloud, false); + + t.end(); +}); + +// Create Same Variable twice. +test('createVariable2', t => { + const target = new Target(); + target.createVariable('foo', 'bar', Variable.SCALAR_TYPE); + target.createVariable('foo', 'bar', Variable.SCALAR_TYPE); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 1); + + t.end(); +}); + +// Create a list +test('createListVariable creates a list', t => { + const target = new Target(); + target.createVariable('foo', 'bar', Variable.LIST_TYPE); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 1); + const variable = variables[Object.keys(variables)[0]]; + t.equal(variable.id, 'foo'); + t.equal(variable.name, 'bar'); + t.equal(variable.type, Variable.LIST_TYPE); + t.assert(variable.value instanceof Array, true); + t.equal(variable.value.length, 0); + t.equal(variable.isCloud, false); + + t.end(); +}); + +test('createVariable throws when given invalid type', t => { + const target = new Target(); + t.throws( + (() => target.createVariable('foo', 'bar', 'baz')), + new Error('Invalid variable type: baz') + ); + + t.end(); +}); + +// Rename Variable tests. +test('renameVariable', t => { + const target = new Target(); + target.createVariable('foo', 'bar', Variable.SCALAR_TYPE); + target.renameVariable('foo', 'bar2'); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 1); + const variable = variables[Object.keys(variables)[0]]; + t.equal(variable.id, 'foo'); + t.equal(variable.name, 'bar2'); + t.equal(variable.value, 0); + t.equal(variable.isCloud, false); + + t.end(); +}); + +// Rename Variable that doesn't exist. +test('renameVariable2', t => { + const target = new Target(); + target.renameVariable('foo', 'bar2'); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 0); + + t.end(); +}); + +// Rename Variable that with id that exists as another variable's name. +// Expect no change. +test('renameVariable3', t => { + const target = new Target(); + target.createVariable('foo1', 'foo', Variable.SCALAR_TYPE); + target.renameVariable('foo', 'bar2'); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 1); + const variable = variables[Object.keys(variables)[0]]; + t.equal(variable.id, 'foo1'); + t.equal(variable.name, 'foo'); + + t.end(); +}); + +// Delete Variable tests. +test('deleteVariable', t => { + const target = new Target(); + target.createVariable('foo', 'bar', Variable.SCALAR_TYPE); + target.deleteVariable('foo'); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 0); + + t.end(); +}); + +// Delete Variable that doesn't exist. +test('deleteVariable2', t => { + const target = new Target(); + target.deleteVariable('foo'); + + const variables = target.variables; + t.equal(Object.keys(variables).length, 0); + + t.end(); +}); + +test('lookupOrCreateList creates a list if var with given id does not exist', t => { + const target = new Target(); + const variables = target.variables; + + t.equal(Object.keys(variables).length, 0); + const listVar = target.lookupOrCreateList('foo', 'bar'); + t.equal(Object.keys(variables).length, 1); + t.equal(listVar.id, 'foo'); + t.equal(listVar.name, 'bar'); + + t.end(); +}); + +test('lookupOrCreateList returns list if one with given id exists', t => { + const target = new Target(); + const variables = target.variables; + + t.equal(Object.keys(variables).length, 0); + target.createVariable('foo', 'bar', Variable.LIST_TYPE); + t.equal(Object.keys(variables).length, 1); + + const listVar = target.lookupOrCreateList('foo', 'bar'); + t.equal(Object.keys(variables).length, 1); + t.equal(listVar.id, 'foo'); + t.equal(listVar.name, 'bar'); + + t.end(); +}); + +test('lookupBroadcastMsg returns the var with given id if exists', t => { + const target = new Target(); + const variables = target.variables; + + t.equal(Object.keys(variables).length, 0); + target.createVariable('foo', 'bar', Variable.BROADCAST_MESSAGE_TYPE); + t.equal(Object.keys(variables).length, 1); + + const broadcastMsg = target.lookupBroadcastMsg('foo', 'bar'); + t.equal(Object.keys(variables).length, 1); + t.equal(broadcastMsg.id, 'foo'); + t.equal(broadcastMsg.name, 'bar'); + + t.end(); +}); diff --git a/test/unit/engine_thread.js b/test/unit/engine_thread.js index 3bda4fc6..15fab7c0 100644 --- a/test/unit/engine_thread.js +++ b/test/unit/engine_thread.js @@ -1,8 +1,279 @@ -var test = require('tap').test; -var Thread = require('../../src/engine/thread'); +const test = require('tap').test; +const Thread = require('../../src/engine/thread'); +const RenderedTarget = require('../../src/sprites/rendered-target'); +const Sprite = require('../../src/sprites/sprite'); -test('spec', function (t) { +test('spec', t => { t.type(Thread, 'function'); - // @todo + + const th = new Thread('arbitraryString'); + t.type(th, 'object'); + t.ok(th instanceof Thread); + t.type(th.pushStack, 'function'); + t.type(th.reuseStackForNextBlock, 'function'); + t.type(th.popStack, 'function'); + t.type(th.stopThisScript, 'function'); + t.type(th.peekStack, 'function'); + t.type(th.peekStackFrame, 'function'); + t.type(th.peekParentStackFrame, 'function'); + t.type(th.pushReportedValue, 'function'); + t.type(th.pushParam, 'function'); + t.type(th.peekStack, 'function'); + t.type(th.getParam, 'function'); + t.type(th.atStackTop, 'function'); + t.type(th.goToNextBlock, 'function'); + t.type(th.isRecursiveCall, 'function'); + + t.end(); +}); + +test('pushStack', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + + t.end(); +}); + +test('popStack', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + t.strictEquals(th.popStack(), 'arbitraryString'); + t.strictEquals(th.popStack(), undefined); + + t.end(); +}); + +test('atStackTop', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + th.pushStack('secondString'); + t.strictEquals(th.atStackTop(), false); + th.popStack(); + t.strictEquals(th.atStackTop(), true); + + t.end(); +}); + +test('reuseStackForNextBlock', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + th.reuseStackForNextBlock('secondString'); + t.strictEquals(th.popStack(), 'secondString'); + + t.end(); +}); + +test('peekStackFrame', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + t.strictEquals(th.peekStackFrame().warpMode, false); + th.popStack(); + t.strictEquals(th.peekStackFrame(), null); + + t.end(); +}); + +test('peekParentStackFrame', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + th.peekStackFrame().warpMode = true; + t.strictEquals(th.peekParentStackFrame(), null); + th.pushStack('secondString'); + t.strictEquals(th.peekParentStackFrame().warpMode, true); + + t.end(); +}); + +test('pushReportedValue', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + th.pushStack('secondString'); + th.pushReportedValue('value'); + t.strictEquals(th.peekParentStackFrame().reported.null, 'value'); + + t.end(); +}); + +test('peekStack', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + t.strictEquals(th.peekStack(), 'arbitraryString'); + th.popStack(); + t.strictEquals(th.peekStack(), null); + + t.end(); +}); + +test('PushGetParam', t => { + const th = new Thread('arbitraryString'); + th.pushStack('arbitraryString'); + th.pushParam('testParam', 'testValue'); + t.strictEquals(th.peekStackFrame().params.testParam, 'testValue'); + t.strictEquals(th.getParam('testParam'), 'testValue'); + // Params outside of define stack always evaluate to null + t.strictEquals(th.getParam('nonExistentParam'), null); + + t.end(); +}); + +test('goToNextBlock', t => { + const th = new Thread('arbitraryString'); + const s = new Sprite(); + const rt = new RenderedTarget(s, null); + const block1 = {fields: Object, + id: 'arbitraryString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: 'secondString', + opcode: 'motion_movesteps', + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + const block2 = {fields: Object, + id: 'secondString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: null, + opcode: 'procedures_call', + mutation: {proccode: 'fakeCode'}, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + + rt.blocks.createBlock(block1); + rt.blocks.createBlock(block2); + rt.blocks.createBlock(block2); + th.target = rt; + + t.strictEquals(th.peekStack(), null); + th.pushStack('secondString'); + t.strictEquals(th.peekStack(), 'secondString'); + th.goToNextBlock(); + t.strictEquals(th.peekStack(), null); + th.pushStack('secondString'); + th.pushStack('arbitraryString'); + t.strictEquals(th.peekStack(), 'arbitraryString'); + th.goToNextBlock(); + t.strictEquals(th.peekStack(), 'secondString'); + th.goToNextBlock(); + t.strictEquals(th.peekStack(), null); + + t.end(); +}); + +test('stopThisScript', t => { + const th = new Thread('arbitraryString'); + const s = new Sprite(); + const rt = new RenderedTarget(s, null); + const block1 = {fields: Object, + id: 'arbitraryString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: null, + opcode: 'motion_movesteps', + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + const block2 = {fields: Object, + id: 'secondString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: null, + opcode: 'procedures_call', + mutation: {proccode: 'fakeCode'}, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + + rt.blocks.createBlock(block1); + rt.blocks.createBlock(block2); + th.target = rt; + + th.stopThisScript(); + t.strictEquals(th.peekStack(), null); + th.pushStack('arbitraryString'); + t.strictEquals(th.peekStack(), 'arbitraryString'); + th.stopThisScript(); + t.strictEquals(th.peekStack(), null); + th.pushStack('arbitraryString'); + th.pushStack('secondString'); + th.stopThisScript(); + t.strictEquals(th.peekStack(), 'secondString'); + + t.end(); +}); + +test('isRecursiveCall', t => { + const th = new Thread('arbitraryString'); + const s = new Sprite(); + const rt = new RenderedTarget(s, null); + const block1 = {fields: Object, + id: 'arbitraryString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: null, + opcode: 'motion_movesteps', + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + const block2 = {fields: Object, + id: 'secondString', + inputs: Object, + STEPS: Object, + block: 'fakeBlock', + name: 'STEPS', + next: null, + opcode: 'procedures_call', + mutation: {proccode: 'fakeCode'}, + parent: null, + shadow: false, + topLevel: true, + x: 0, + y: 0 + }; + + rt.blocks.createBlock(block1); + rt.blocks.createBlock(block2); + th.target = rt; + + t.strictEquals(th.isRecursiveCall('fakeCode'), false); + th.pushStack('secondString'); + t.strictEquals(th.isRecursiveCall('fakeCode'), false); + th.pushStack('arbitraryString'); + t.strictEquals(th.isRecursiveCall('fakeCode'), true); + th.pushStack('arbitraryString'); + t.strictEquals(th.isRecursiveCall('fakeCode'), true); + th.popStack(); + t.strictEquals(th.isRecursiveCall('fakeCode'), true); + th.popStack(); + t.strictEquals(th.isRecursiveCall('fakeCode'), false); + th.popStack(); + t.strictEquals(th.isRecursiveCall('fakeCode'), false); + t.end(); }); diff --git a/test/unit/extension_music.js b/test/unit/extension_music.js new file mode 100644 index 00000000..93106297 --- /dev/null +++ b/test/unit/extension_music.js @@ -0,0 +1,45 @@ +const test = require('tap').test; +const Music = require('../../src/extensions/scratch3_music/index.js'); +const runtime = Object.create(null); + +const blocks = new Music(runtime); + +const util = { + stackFrame: Object.create(null), + target: { + audioPlayer: null + }, + yield: () => null +}; + +test('playDrum uses 1-indexing and wrap clamps', t => { + // Stub playDrumNum + let playedDrum; + blocks._playDrumNum = (_util, drum) => (playedDrum = drum); + + let args = {DRUM: 1}; + blocks.playDrumForBeats(args, util); + t.strictEqual(playedDrum, 0); + + args = {DRUM: blocks.DRUM_INFO.length + 1}; + blocks.playDrumForBeats(args, util); + t.strictEqual(playedDrum, 0); + + t.end(); +}); + +test('setInstrument uses 1-indexing and wrap clamps', t => { + // Stub getMusicState + const state = {currentInstrument: 0}; + blocks._getMusicState = () => state; + + let args = {INSTRUMENT: 1}; + blocks.setInstrument(args, util); + t.strictEqual(state.currentInstrument, 0); + + args = {INSTRUMENT: blocks.INSTRUMENT_INFO.length + 1}; + blocks.setInstrument(args, util); + t.strictEqual(state.currentInstrument, 0); + + t.end(); +}); diff --git a/test/unit/import_sb2.js b/test/unit/import_sb2.js deleted file mode 100644 index be4d6286..00000000 --- a/test/unit/import_sb2.js +++ /dev/null @@ -1,88 +0,0 @@ -var fs = require('fs'); -var path = require('path'); -var test = require('tap').test; - -var clone = require('../../src/sprites/clone'); -var runtime = require('../../src/engine/runtime'); -var sb2 = require('../../src/import/sb2import'); - -test('spec', function (t) { - t.type(sb2, 'function'); - t.end(); -}); - -test('default', function (t) { - // Get SB2 JSON (string) - var uri = path.resolve(__dirname, '../fixtures/default.json'); - var file = fs.readFileSync(uri, 'utf8'); - - // Create runtime instance & load SB2 into it - var rt = new runtime(); - sb2(file, rt); - - // Test - t.type(file, 'string'); - t.type(rt, 'object'); - t.type(rt.targets, 'object'); - - t.ok(rt.targets[0] instanceof clone); - t.type(rt.targets[0].id, 'string'); - t.type(rt.targets[0].blocks, 'object'); - t.type(rt.targets[0].variables, 'object'); - t.type(rt.targets[0].lists, 'object'); - - t.equal(rt.targets[0].isOriginal, true); - t.equal(rt.targets[0].currentCostume, 0); - t.equal(rt.targets[0].isOriginal, true); - t.equal(rt.targets[0].isStage, true); - - t.ok(rt.targets[1] instanceof clone); - t.type(rt.targets[1].id, 'string'); - t.type(rt.targets[1].blocks, 'object'); - t.type(rt.targets[1].variables, 'object'); - t.type(rt.targets[1].lists, 'object'); - - t.equal(rt.targets[1].isOriginal, true); - t.equal(rt.targets[1].currentCostume, 0); - t.equal(rt.targets[1].isOriginal, true); - t.equal(rt.targets[1].isStage, false); - t.end(); -}); - -test('demo', function (t) { - // Get SB2 JSON (string) - var uri = path.resolve(__dirname, '../fixtures/demo.json'); - var file = fs.readFileSync(uri, 'utf8'); - - // Create runtime instance & load SB2 into it - var rt = new runtime(); - sb2(file, rt); - - // Test - t.type(file, 'string'); - t.type(rt, 'object'); - t.type(rt.targets, 'object'); - - t.ok(rt.targets[0] instanceof clone); - t.type(rt.targets[0].id, 'string'); - t.type(rt.targets[0].blocks, 'object'); - t.type(rt.targets[0].variables, 'object'); - t.type(rt.targets[0].lists, 'object'); - - t.equal(rt.targets[0].isOriginal, true); - t.equal(rt.targets[0].currentCostume, 0); - t.equal(rt.targets[0].isOriginal, true); - t.equal(rt.targets[0].isStage, true); - - t.ok(rt.targets[1] instanceof clone); - t.type(rt.targets[1].id, 'string'); - t.type(rt.targets[1].blocks, 'object'); - t.type(rt.targets[1].variables, 'object'); - t.type(rt.targets[1].lists, 'object'); - - t.equal(rt.targets[1].isOriginal, true); - t.equal(rt.targets[1].currentCostume, 0); - t.equal(rt.targets[1].isOriginal, true); - t.equal(rt.targets[1].isStage, false); - t.end(); -}); diff --git a/test/unit/io_clock.js b/test/unit/io_clock.js new file mode 100644 index 00000000..1978911e --- /dev/null +++ b/test/unit/io_clock.js @@ -0,0 +1,34 @@ +const test = require('tap').test; +const Clock = require('../../src/io/clock'); +const Runtime = require('../../src/engine/runtime'); + +test('spec', t => { + const rt = new Runtime(); + const c = new Clock(rt); + + t.type(Clock, 'function'); + t.type(c, 'object'); + t.type(c.projectTimer, 'function'); + t.type(c.pause, 'function'); + t.type(c.resume, 'function'); + t.type(c.resetProjectTimer, 'function'); + t.end(); +}); + +test('cycle', t => { + const rt = new Runtime(); + const c = new Clock(rt); + + t.ok(c.projectTimer() <= 0.1); + setTimeout(() => { + c.resetProjectTimer(); + setTimeout(() => { + t.ok(c.projectTimer() > 0); + c.pause(); + t.ok(c.projectTimer() > 0); + c.resume(); + t.ok(c.projectTimer() > 0); + t.end(); + }, 100); + }, 100); +}); diff --git a/test/unit/io_deviceManager.js b/test/unit/io_deviceManager.js new file mode 100644 index 00000000..499b995f --- /dev/null +++ b/test/unit/io_deviceManager.js @@ -0,0 +1,39 @@ +const test = require('tap').test; +const DeviceManager = require('../../src/io/deviceManager'); + +test('spec', t => { + const deviceManager = new DeviceManager(); + + t.type(DeviceManager, 'function'); + t.type(deviceManager, 'object'); + t.type(deviceManager.list, 'function'); + t.type(deviceManager.open, 'function'); + t.type(deviceManager.searchAndConnect, 'function'); + t.type(deviceManager.isConnected, 'boolean'); + t.end(); +}); + +test('default connected', t => { + const deviceManager = new DeviceManager(); + + t.strictEqual(deviceManager.isConnected, true); + t.end(); +}); + +test('list', t => { + const deviceManager = new DeviceManager(); + deviceManager + .list('test', 'test', null) + .then( + body => { + // SDM is running + t.type(body, 'object'); + t.end(); + }, + err => { + // If SDM is not running error is expected, continue + t.true(typeof err === 'object' || typeof err === 'undefined'); + t.end(); + } + ); +}); diff --git a/test/unit/io_keyboard.js b/test/unit/io_keyboard.js new file mode 100644 index 00000000..a0a38f74 --- /dev/null +++ b/test/unit/io_keyboard.js @@ -0,0 +1,73 @@ +const test = require('tap').test; +const Keyboard = require('../../src/io/keyboard'); +const Runtime = require('../../src/engine/runtime'); + +test('spec', t => { + const rt = new Runtime(); + const k = new Keyboard(rt); + + t.type(k, 'object'); + t.type(k.postData, 'function'); + t.type(k.getKeyIsDown, 'function'); + t.end(); +}); + +test('space', t => { + const rt = new Runtime(); + const k = new Keyboard(rt); + + k.postData({ + keyCode: 32, + isDown: true + }); + t.strictDeepEquals(k._keysPressed, [32]); + t.strictEquals(k.getKeyIsDown('space'), true); + t.strictEquals(k.getKeyIsDown('any'), true); + t.end(); +}); + +test('letter', t => { + const rt = new Runtime(); + const k = new Keyboard(rt); + + k.postData({ + keyCode: 65, + isDown: true + }); + t.strictDeepEquals(k._keysPressed, [65]); + t.strictEquals(k.getKeyIsDown('a'), true); + t.strictEquals(k.getKeyIsDown('any'), true); + t.end(); +}); + +test('number', t => { + const rt = new Runtime(); + const k = new Keyboard(rt); + + k.postData({ + keyCode: 49, + isDown: true + }); + t.strictDeepEquals(k._keysPressed, [49]); + t.strictEquals(k.getKeyIsDown(49), true); + t.strictEquals(k.getKeyIsDown('any'), true); + t.end(); +}); + +test('keyup', t => { + const rt = new Runtime(); + const k = new Keyboard(rt); + + k.postData({ + keyCode: 37, + isDown: true + }); + k.postData({ + keyCode: 37, + isDown: false + }); + t.strictDeepEquals(k._keysPressed, []); + t.strictEquals(k.getKeyIsDown(37), false); + t.strictEquals(k.getKeyIsDown('any'), false); + t.end(); +}); diff --git a/test/unit/io_mouse.js b/test/unit/io_mouse.js new file mode 100644 index 00000000..48613fdb --- /dev/null +++ b/test/unit/io_mouse.js @@ -0,0 +1,72 @@ +const test = require('tap').test; +const Mouse = require('../../src/io/mouse'); +const Runtime = require('../../src/engine/runtime'); + +test('spec', t => { + const rt = new Runtime(); + const m = new Mouse(rt); + + t.type(m, 'object'); + t.type(m.postData, 'function'); + t.type(m.getClientX, 'function'); + t.type(m.getClientY, 'function'); + t.type(m.getScratchX, 'function'); + t.type(m.getScratchY, 'function'); + t.type(m.getIsDown, 'function'); + t.end(); +}); + +test('mouseUp', t => { + const rt = new Runtime(); + const m = new Mouse(rt); + + m.postData({ + x: -20, + y: 10, + isDown: false, + canvasWidth: 480, + canvasHeight: 360 + }); + t.strictEquals(m.getClientX(), -20); + t.strictEquals(m.getClientY(), 10); + t.strictEquals(m.getScratchX(), -240); + t.strictEquals(m.getScratchY(), 170); + t.strictEquals(m.getIsDown(), false); + t.end(); +}); + +test('mouseDown', t => { + const rt = new Runtime(); + const m = new Mouse(rt); + + m.postData({ + x: 10, + y: 400, + isDown: true, + canvasWidth: 480, + canvasHeight: 360 + }); + t.strictEquals(m.getClientX(), 10); + t.strictEquals(m.getClientY(), 400); + t.strictEquals(m.getScratchX(), -230); + t.strictEquals(m.getScratchY(), -180); + t.strictEquals(m.getIsDown(), true); + t.end(); +}); + +test('at zoomed scale', t => { + const rt = new Runtime(); + const m = new Mouse(rt); + + m.postData({ + x: 240, + y: 540, + canvasWidth: 960, + canvasHeight: 720 + }); + t.strictEquals(m.getClientX(), 240); + t.strictEquals(m.getClientY(), 540); + t.strictEquals(m.getScratchX(), -120); + t.strictEquals(m.getScratchY(), -90); + t.end(); +}); diff --git a/test/unit/serialization_sb2.js b/test/unit/serialization_sb2.js new file mode 100644 index 00000000..d5aaa024 --- /dev/null +++ b/test/unit/serialization_sb2.js @@ -0,0 +1,70 @@ +const path = require('path'); +const test = require('tap').test; +const extract = require('../fixtures/extract'); + +const RenderedTarget = require('../../src/sprites/rendered-target'); +const Runtime = require('../../src/engine/runtime'); +const sb2 = require('../../src/serialization/sb2'); + +test('spec', t => { + t.type(sb2, 'object'); + t.type(sb2.deserialize, 'function'); + t.end(); +}); + +test('default', t => { + // Get SB2 JSON (string) + const uri = path.resolve(__dirname, '../fixtures/default.sb2'); + const file = extract(uri); + const json = JSON.parse(file); + + // Create runtime instance & load SB2 into it + const rt = new Runtime(); + sb2.deserialize(json, rt).then(({targets}) => { + // Test + t.type(file, 'string'); + t.type(json, 'object'); + t.type(rt, 'object'); + t.type(targets, 'object'); + + t.ok(targets[0] instanceof RenderedTarget); + t.type(targets[0].id, 'string'); + t.type(targets[0].blocks, 'object'); + t.type(targets[0].variables, 'object'); + t.type(targets[0].lists, 'object'); + + t.equal(targets[0].isOriginal, true); + t.equal(targets[0].currentCostume, 0); + t.equal(targets[0].isOriginal, true); + t.equal(targets[0].isStage, true); + + t.ok(targets[1] instanceof RenderedTarget); + t.type(targets[1].id, 'string'); + t.type(targets[1].blocks, 'object'); + t.type(targets[1].variables, 'object'); + t.type(targets[1].lists, 'object'); + + t.equal(targets[1].isOriginal, true); + t.equal(targets[1].currentCostume, 0); + t.equal(targets[1].isOriginal, true); + t.equal(targets[1].isStage, false); + t.end(); + }); +}); + +test('data scoping', t => { + // Get SB2 JSON (string) + const uri = path.resolve(__dirname, '../fixtures/data.sb2'); + const file = extract(uri); + const json = JSON.parse(file); + + // Create runtime instance & load SB2 into it + const rt = new Runtime(); + sb2.deserialize(json, rt).then(({targets}) => { + const globalVariableIds = Object.keys(targets[0].variables); + const localVariableIds = Object.keys(targets[1].variables); + t.equal(targets[0].variables[globalVariableIds[0]].name, 'foo'); + t.equal(targets[1].variables[localVariableIds[0]].name, 'local'); + t.end(); + }); +}); diff --git a/test/unit/serialization_sb3.js b/test/unit/serialization_sb3.js new file mode 100644 index 00000000..5c2b8a1e --- /dev/null +++ b/test/unit/serialization_sb3.js @@ -0,0 +1,22 @@ +const test = require('tap').test; +const VirtualMachine = require('../../src/index'); +const sb3 = require('../../src/serialization/sb3'); +const demoSb3 = require('../fixtures/demo.json'); + +test('serialize', t => { + const vm = new VirtualMachine(); + vm.fromJSON(JSON.stringify(demoSb3)); + const result = sb3.serialize(vm.runtime); + // @todo Analyze + t.type(JSON.stringify(result), 'string'); + t.end(); +}); + +test('deserialize', t => { + const vm = new VirtualMachine(); + sb3.deserialize('', vm.runtime).then(({targets}) => { + // @todo Analyze + t.type(targets, 'object'); + t.end(); + }); +}); diff --git a/test/unit/spec.js b/test/unit/spec.js new file mode 100644 index 00000000..48b0fd6a --- /dev/null +++ b/test/unit/spec.js @@ -0,0 +1,36 @@ +const test = require('tap').test; +const VirtualMachine = require('../../src/index'); + +test('interface', t => { + const vm = new VirtualMachine(); + t.type(vm, 'object'); + t.type(vm.start, 'function'); + t.type(vm.greenFlag, 'function'); + t.type(vm.setTurboMode, 'function'); + t.type(vm.setCompatibilityMode, 'function'); + t.type(vm.stopAll, 'function'); + t.type(vm.clear, 'function'); + + t.type(vm.getPlaygroundData, 'function'); + t.type(vm.postIOData, 'function'); + + t.type(vm.loadProject, 'function'); + t.type(vm.addSprite2, 'function'); + t.type(vm.addCostume, 'function'); + t.type(vm.addBackdrop, 'function'); + t.type(vm.addSound, 'function'); + t.type(vm.deleteCostume, 'function'); + t.type(vm.deleteSound, 'function'); + t.type(vm.renameSprite, 'function'); + t.type(vm.deleteSprite, 'function'); + + t.type(vm.attachRenderer, 'function'); + t.type(vm.blockListener, 'function'); + t.type(vm.flyoutBlockListener, 'function'); + t.type(vm.setEditingTarget, 'function'); + + t.type(vm.emitTargetsUpdate, 'function'); + t.type(vm.emitWorkspaceUpdate, 'function'); + t.type(vm.postSpriteInfo, 'function'); + t.end(); +}); diff --git a/test/unit/sprites_clone.js b/test/unit/sprites_clone.js deleted file mode 100644 index 246e1b95..00000000 --- a/test/unit/sprites_clone.js +++ /dev/null @@ -1,13 +0,0 @@ -var test = require('tap').test; -var Clone = require('../../src/sprites/clone'); -var Sprite = require('../../src/sprites/sprite'); - -test('clone effects', function (t) { - // Create two clones and ensure they have different graphic effect objects. - // Regression test for Github issue #224 - var spr = new Sprite(); - var a = new Clone(spr, null); - var b = new Clone(spr, null); - t.ok(a.effects !== b.effects); - t.end(); -}); diff --git a/test/unit/sprites_rendered-target.js b/test/unit/sprites_rendered-target.js new file mode 100644 index 00000000..332a7811 --- /dev/null +++ b/test/unit/sprites_rendered-target.js @@ -0,0 +1,401 @@ +const test = require('tap').test; +const RenderedTarget = require('../../src/sprites/rendered-target'); +const Sprite = require('../../src/sprites/sprite'); +const Runtime = require('../../src/engine/runtime'); +const FakeRenderer = require('../fixtures/fake-renderer'); + +test('clone effects', t => { + // Create two clones and ensure they have different graphic effect objects. + // Regression test for Github issue #224 + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + const b = new RenderedTarget(spr, null); + t.ok(a.effects !== b.effects); + t.end(); +}); + +test('setxy', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setXY(123, 321, true); + t.equals(a.x, 123); + t.equals(a.y, 321); + t.end(); +}); + +test('direction', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setDirection(123); + t.equals(a._getRenderedDirectionAndScale().direction, 123); + t.end(); +}); + +test('setSay', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setSay(); + a.setSay('types not specified', 'message'); + t.end(); +}); + +test('setVisible', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setVisible(true); + t.end(); +}); + +test('setSize', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setSize(123); + t.equals(a._getRenderedDirectionAndScale().scale[0], 123); + t.end(); +}); + +test('set and clear effects', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + for (const effect in a.effects) { + a.setEffect(effect, 1); + t.equals(a.effects[effect], 1); + } + a.clearEffects(); + for (const effect in a.effects) { + t.equals(a.effects[effect], 0); + } + t.end(); +}); + +test('setCostume', t => { + const o = new Object(); + const s = new Sprite(); + const r = new Runtime(); + s.costumes = [o]; + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setCostume(0); + t.end(); +}); + +test('deleteCostume', t => { + const o1 = {id: 1}; + const o2 = {id: 2}; + const o3 = {id: 3}; + const o4 = {id: 4}; + const o5 = {id: 5}; + + const s = new Sprite(); + const r = new Runtime(); + s.costumes = [o1, o2, o3]; + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + + // x* Costume 1 * Costume 2 + // Costume 2 => Costume 3 + // Costume 3 + a.setCostume(0); + a.deleteCostume(0); + t.deepEqual(a.sprite.costumes, [o2, o3]); + t.equals(a.currentCostume, 0); + + // Costume 1 Costume 1 + // x* Costume 2 => * Costume 3 + // Costume 3 + a.sprite.costumes = [o1, o2, o3]; + a.setCostume(1); + a.deleteCostume(1); + t.deepEqual(a.sprite.costumes, [o1, o3]); + t.equals(a.currentCostume, 1); + + // Costume 1 Costume 1 + // Costume 2 => * Costume 2 + // x* Costume 3 + a.sprite.costumes = [o1, o2, o3]; + a.setCostume(2); + a.deleteCostume(2); + t.deepEqual(a.sprite.costumes, [o1, o2]); + t.equals(a.currentCostume, 1); + + // Refuses to delete only costume + a.sprite.costumes = [o1]; + a.setCostume(0); + a.deleteCostume(0); + t.deepEqual(a.sprite.costumes, [o1]); + t.equals(a.currentCostume, 0); + + // Costume 1 Costume 1 + // x Costume 2 Costume 3 + // Costume 3 => * Costume 4 + // * Costume 4 Costume 5 + // Costume 5 + a.sprite.costumes = [o1, o2, o3, o4, o5]; + a.setCostume(3); + a.deleteCostume(1); + t.deepEqual(a.sprite.costumes, [o1, o3, o4, o5]); + t.equals(a.currentCostume, 2); + + // Costume 1 Costume 1 + // * Costume 2 * Costume 2 + // Costume 3 => Costume 3 + // x Costume 4 Costume 5 + // Costume 5 + a.sprite.costumes = [o1, o2, o3, o4, o5]; + a.setCostume(1); + a.deleteCostume(3); + t.deepEqual(a.sprite.costumes, [o1, o2, o3, o5]); + t.equals(a.currentCostume, 1); + + // Costume 1 Costume 1 + // * Costume 2 * Costume 2 + // Costume 3 => Costume 3 + // Costume 4 Costume 4 + // x Costume 5 + a.sprite.costumes = [o1, o2, o3, o4, o5]; + a.setCostume(1); + a.deleteCostume(4); + t.deepEqual(a.sprite.costumes, [o1, o2, o3, o4]); + t.equals(a.currentCostume, 1); + t.end(); +}); + +test('deleteSound', t => { + const o1 = {id: 1}; + const o2 = {id: 2}; + const o3 = {id: 3}; + + const s = new Sprite(); + const r = new Runtime(); + s.sounds = [o1, o2, o3]; + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + + a.deleteSound(0); + t.deepEqual(a.sprite.sounds, [o2, o3]); + + // Allows deleting the only sound + a.sprite.sounds = [o1]; + a.deleteSound(0); + t.deepEqual(a.sprite.sounds, []); + + t.end(); +}); + +test('setRotationStyle', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const renderer = new FakeRenderer(); + a.renderer = renderer; + a.setRotationStyle(RenderedTarget.ROTATION_STYLE_NONE); + t.end(); +}); + +test('getBounds', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.getBounds().top, 0); + a.setXY(241, 241); + t.equals(a.getBounds().top, 241); + t.end(); +}); + +test('isTouchingPoint', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.isTouchingPoint(), true); + t.end(); +}); + +test('isTouchingEdge', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.isTouchingEdge(), false); + a.setXY(1000, 1000); + t.equals(a.isTouchingEdge(), true); + t.end(); +}); + +test('isTouchingSprite', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.isTouchingSprite('fake'), false); + t.end(); +}); + +test('isTouchingColor', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.isTouchingColor(), false); + t.end(); +}); + +test('colorIsTouchingColor', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.colorIsTouchingColor(), false); + t.end(); +}); + +test('layers', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + const o = new Object(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + a.goToFront(); + t.equals(a.renderer.order, 5); + a.goBackwardLayers(2); + t.equals(a.renderer.order, 3); + a.goToBack(); + t.equals(a.renderer.order, 1); + a.goForwardLayers(1); + t.equals(a.renderer.order, 2); + o.drawableID = 999; + a.goBehindOther(o); + t.equals(a.renderer.order, 1); + t.end(); +}); + +test('keepInFence', t => { + const s = new Sprite(); + const r = new Runtime(); + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const a = new RenderedTarget(s, r); + a.renderer = renderer; + t.equals(a.keepInFence(1000, 1000)[0], 240); + t.equals(a.keepInFence(-1000, 1000)[0], -240); + t.equals(a.keepInFence(1000, 1000)[1], 180); + t.equals(a.keepInFence(1000, -1000)[1], -180); + t.end(); +}); + +test('#stopAll clears graphics effects', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + const effectName = 'brightness'; + a.setEffect(effectName, 100); + a.onStopAll(); + t.equals(a.effects[effectName], 0); + t.end(); +}); + +test('#getCostumes returns the costumes', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + const costumes = [1, 2, 3]; + a.sprite.costumes = costumes; + t.equals(a.getCostumes(), costumes); + t.end(); +}); + +test('#getSounds returns the sounds', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + const sounds = [1, 2, 3]; + a.sprite.sounds = sounds; + t.equals(a.getSounds(), sounds); + t.end(); +}); + +test('#toJSON returns the sounds and costumes', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + const sounds = [1, 2, 3]; + const costumes = ['a', 'b', 'c']; + a.sprite.sounds = sounds; + a.sprite.costumes = costumes; + t.same(a.toJSON().sounds, sounds); + t.same(a.toJSON().costumes, costumes); + t.end(); +}); + +test('#addSound does not duplicate names', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + a.sprite.sounds = [{name: 'first'}]; + a.addSound({name: 'first'}); + t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'first2'}]); + t.end(); +}); + +test('#addCostume does not duplicate names', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + a.sprite.costumes = [{name: 'first'}]; + a.addCostume({name: 'first'}); + t.deepEqual(a.sprite.costumes, [{name: 'first'}, {name: 'first2'}]); + t.end(); +}); + +test('#renameSound does not duplicate names', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + a.sprite.sounds = [{name: 'first'}, {name: 'second'}]; + a.renameSound(0, 'first'); // Shouldn't increment the name, noop + t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'second'}]); + a.renameSound(1, 'first'); + t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'first2'}]); + t.end(); +}); + +test('#renameCostume does not duplicate names', t => { + const spr = new Sprite(); + const a = new RenderedTarget(spr, null); + a.sprite.costumes = [{name: 'first'}, {name: 'second'}]; + a.renameCostume(0, 'first'); // Shouldn't increment the name, noop + t.deepEqual(a.sprite.costumes, [{name: 'first'}, {name: 'second'}]); + a.renameCostume(1, 'first'); + t.deepEqual(a.sprite.costumes, [{name: 'first'}, {name: 'first2'}]); + t.end(); +}); diff --git a/test/unit/util_cast.js b/test/unit/util_cast.js index 1d372213..8d30df36 100644 --- a/test/unit/util_cast.js +++ b/test/unit/util_cast.js @@ -1,7 +1,7 @@ -var test = require('tap').test; -var cast = require('../../src/util/cast'); +const test = require('tap').test; +const cast = require('../../src/util/cast'); -test('toNumber', function (t) { +test('toNumber', t => { // Numeric t.strictEqual(cast.toNumber(0), 0); t.strictEqual(cast.toNumber(1), 1); @@ -27,7 +27,7 @@ test('toNumber', function (t) { t.end(); }); -test('toBoolean', function (t) { +test('toBoolean', t => { // Numeric t.strictEqual(cast.toBoolean(0), false); t.strictEqual(cast.toBoolean(1), true); @@ -50,7 +50,7 @@ test('toBoolean', function (t) { t.end(); }); -test('toString', function (t) { +test('toString', t => { // Numeric t.strictEqual(cast.toString(0), '0'); t.strictEqual(cast.toString(1), '1'); @@ -73,25 +73,44 @@ test('toString', function (t) { t.end(); }); -test('toRbgColorList', function (t) { +test('toRbgColorList', t => { // Hex (minimal, see "color" util tests) - t.deepEqual(cast.toRgbColorList('#000'), [0,0,0]); - t.deepEqual(cast.toRgbColorList('#000000'), [0,0,0]); - t.deepEqual(cast.toRgbColorList('#fff'), [255,255,255]); - t.deepEqual(cast.toRgbColorList('#ffffff'), [255,255,255]); + t.deepEqual(cast.toRgbColorList('#000'), [0, 0, 0]); + t.deepEqual(cast.toRgbColorList('#000000'), [0, 0, 0]); + t.deepEqual(cast.toRgbColorList('#fff'), [255, 255, 255]); + t.deepEqual(cast.toRgbColorList('#ffffff'), [255, 255, 255]); // Decimal (minimal, see "color" util tests) - t.deepEqual(cast.toRgbColorList(0), [0,0,0]); - t.deepEqual(cast.toRgbColorList(1), [0,0,1]); - t.deepEqual(cast.toRgbColorList(16777215), [255,255,255]); + t.deepEqual(cast.toRgbColorList(0), [0, 0, 0]); + t.deepEqual(cast.toRgbColorList(1), [0, 0, 1]); + t.deepEqual(cast.toRgbColorList(16777215), [255, 255, 255]); // Malformed - t.deepEqual(cast.toRgbColorList('ffffff'), [0,0,0]); - t.deepEqual(cast.toRgbColorList('foobar'), [0,0,0]); + t.deepEqual(cast.toRgbColorList('ffffff'), [0, 0, 0]); + t.deepEqual(cast.toRgbColorList('foobar'), [0, 0, 0]); t.end(); }); -test('compare', function (t) { +test('toRbgColorObject', t => { + // Hex (minimal, see "color" util tests) + t.deepEqual(cast.toRgbColorObject('#000'), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('#000000'), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('#fff'), {r: 255, g: 255, b: 255}); + t.deepEqual(cast.toRgbColorObject('#ffffff'), {r: 255, g: 255, b: 255}); + + // Decimal (minimal, see "color" util tests) + t.deepEqual(cast.toRgbColorObject(0), {a: 255, r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject(1), {a: 255, r: 0, g: 0, b: 1}); + t.deepEqual(cast.toRgbColorObject(16777215), {a: 255, r: 255, g: 255, b: 255}); + t.deepEqual(cast.toRgbColorObject('0x80010203'), {a: 128, r: 1, g: 2, b: 3}); + + // Malformed + t.deepEqual(cast.toRgbColorObject('ffffff'), {a: 255, r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('foobar'), {a: 255, r: 0, g: 0, b: 0}); + t.end(); +}); + +test('compare', t => { // Numeric t.strictEqual(cast.compare(0, 0), 0); t.strictEqual(cast.compare(1, 0), 1); @@ -118,7 +137,7 @@ test('compare', function (t) { t.end(); }); -test('isInt', function (t) { +test('isInt', t => { // Numeric t.strictEqual(cast.isInt(0), true); t.strictEqual(cast.isInt(1), true); @@ -143,9 +162,9 @@ test('isInt', function (t) { t.end(); }); -test('toListIndex', function (t) { - var list = [0,1,2,3,4,5]; - var empty = []; +test('toListIndex', t => { + const list = [0, 1, 2, 3, 4, 5]; + const empty = []; // Valid t.strictEqual(cast.toListIndex(1, list.length), 1); @@ -165,13 +184,13 @@ test('toListIndex', function (t) { t.strictEqual(cast.toListIndex('last', empty.length), cast.LIST_INVALID); // "random" - var random = cast.toListIndex('random', list.length); + const random = cast.toListIndex('random', list.length); t.ok(random <= list.length); t.ok(random > 0); t.strictEqual(cast.toListIndex('random', empty.length), cast.LIST_INVALID); // "any" (alias for "random") - var any = cast.toListIndex('any', list.length); + const any = cast.toListIndex('any', list.length); t.ok(any <= list.length); t.ok(any > 0); t.strictEqual(cast.toListIndex('any', empty.length), cast.LIST_INVALID); diff --git a/test/unit/util_color.js b/test/unit/util_color.js index ba7fa059..61fc04a6 100644 --- a/test/unit/util_color.js +++ b/test/unit/util_color.js @@ -1,7 +1,43 @@ -var test = require('tap').test; -var color = require('../../src/util/color'); +const test = require('tap').test; +const color = require('../../src/util/color'); -test('decimalToHex', function (t) { +/** + * Assert that two HSV colors are similar to each other, within a tolerance. + * @param {Test} t - the Tap test object. + * @param {HSVObject} actual - the first HSV color to compare. + * @param {HSVObject} expected - the other HSV color to compare. + */ +const hsvSimilar = function (t, actual, expected) { + if ((Math.abs(actual.h - expected.h) >= 1) || + (Math.abs(actual.s - expected.s) >= 0.01) || + (Math.abs(actual.v - expected.v) >= 0.01) + ) { + t.fail('HSV colors not similar enough', { + actual: actual, + expected: expected + }); + } +}; + +/** + * Assert that two RGB colors are similar to each other, within a tolerance. + * @param {Test} t - the Tap test object. + * @param {RGBObject} actual - the first RGB color to compare. + * @param {RGBObject} expected - the other RGB color to compare. + */ +const rgbSimilar = function (t, actual, expected) { + if ((Math.abs(actual.r - expected.r) >= 1) || + (Math.abs(actual.g - expected.g) >= 1) || + (Math.abs(actual.b - expected.b) >= 1) + ) { + t.fail('RGB colors not similar enough', { + actual: actual, + expected: expected + }); + } +}; + +test('decimalToHex', t => { t.strictEqual(color.decimalToHex(0), '#000000'); t.strictEqual(color.decimalToHex(1), '#000001'); t.strictEqual(color.decimalToHex(16777215), '#ffffff'); @@ -10,26 +46,26 @@ test('decimalToHex', function (t) { t.end(); }); -test('decimalToRgb', function (t) { - t.deepEqual(color.decimalToRgb(0), {r:0,g:0,b:0}); - t.deepEqual(color.decimalToRgb(1), {r:0,g:0,b:1}); - t.deepEqual(color.decimalToRgb(16777215), {r:255,g:255,b:255}); - t.deepEqual(color.decimalToRgb(-16777215), {r:0,g:0,b:1}); - t.deepEqual(color.decimalToRgb(99999999), {r:245,g:224,b:255}); +test('decimalToRgb', t => { + t.deepEqual(color.decimalToRgb(0), {a: 255, r: 0, g: 0, b: 0}); + t.deepEqual(color.decimalToRgb(1), {a: 255, r: 0, g: 0, b: 1}); + t.deepEqual(color.decimalToRgb(16777215), {a: 255, r: 255, g: 255, b: 255}); + t.deepEqual(color.decimalToRgb(-16777215), {a: 255, r: 0, g: 0, b: 1}); + t.deepEqual(color.decimalToRgb(99999999), {a: 5, r: 245, g: 224, b: 255}); t.end(); }); -test('hexToRgb', function (t) { - t.deepEqual(color.hexToRgb('#000'), {r:0,g:0,b:0}); - t.deepEqual(color.hexToRgb('#000000'), {r:0,g:0,b:0}); - t.deepEqual(color.hexToRgb('#fff'), {r:255,g:255,b:255}); - t.deepEqual(color.hexToRgb('#ffffff'), {r:255,g:255,b:255}); - t.deepEqual(color.hexToRgb('#0fa'), {r:0,g:255,b:170}); - t.deepEqual(color.hexToRgb('#00ffaa'), {r:0,g:255,b:170}); +test('hexToRgb', t => { + t.deepEqual(color.hexToRgb('#000'), {r: 0, g: 0, b: 0}); + t.deepEqual(color.hexToRgb('#000000'), {r: 0, g: 0, b: 0}); + t.deepEqual(color.hexToRgb('#fff'), {r: 255, g: 255, b: 255}); + t.deepEqual(color.hexToRgb('#ffffff'), {r: 255, g: 255, b: 255}); + t.deepEqual(color.hexToRgb('#0fa'), {r: 0, g: 255, b: 170}); + t.deepEqual(color.hexToRgb('#00ffaa'), {r: 0, g: 255, b: 170}); - t.deepEqual(color.hexToRgb('000'), {r:0,g:0,b:0}); - t.deepEqual(color.hexToRgb('fff'), {r:255,g:255,b:255}); - t.deepEqual(color.hexToRgb('00ffaa'), {r:0,g:255,b:170}); + t.deepEqual(color.hexToRgb('000'), {r: 0, g: 0, b: 0}); + t.deepEqual(color.hexToRgb('fff'), {r: 255, g: 255, b: 255}); + t.deepEqual(color.hexToRgb('00ffaa'), {r: 0, g: 255, b: 170}); t.deepEqual(color.hexToRgb('0'), null); t.deepEqual(color.hexToRgb('hello world'), null); @@ -37,21 +73,21 @@ test('hexToRgb', function (t) { t.end(); }); -test('rgbToHex', function (t) { - t.strictEqual(color.rgbToHex({r:0,g:0,b:0}), '#000000'); - t.strictEqual(color.rgbToHex({r:255,g:255,b:255}), '#ffffff'); - t.strictEqual(color.rgbToHex({r:0,g:255,b:170}), '#00ffaa'); +test('rgbToHex', t => { + t.strictEqual(color.rgbToHex({r: 0, g: 0, b: 0}), '#000000'); + t.strictEqual(color.rgbToHex({r: 255, g: 255, b: 255}), '#ffffff'); + t.strictEqual(color.rgbToHex({r: 0, g: 255, b: 170}), '#00ffaa'); t.end(); }); -test('rgbToDecimal', function (t) { - t.strictEqual(color.rgbToDecimal({r:0,g:0,b:0}), 0); - t.strictEqual(color.rgbToDecimal({r:255,g:255,b:255}), 16777215); - t.strictEqual(color.rgbToDecimal({r:0,g:255,b:170}), 65450); +test('rgbToDecimal', t => { + t.strictEqual(color.rgbToDecimal({r: 0, g: 0, b: 0}), 0); + t.strictEqual(color.rgbToDecimal({r: 255, g: 255, b: 255}), 16777215); + t.strictEqual(color.rgbToDecimal({r: 0, g: 255, b: 170}), 65450); t.end(); }); -test('hexToDecimal', function (t) { +test('hexToDecimal', t => { t.strictEqual(color.hexToDecimal('#000'), 0); t.strictEqual(color.hexToDecimal('#000000'), 0); t.strictEqual(color.hexToDecimal('#fff'), 16777215); @@ -60,3 +96,37 @@ test('hexToDecimal', function (t) { t.strictEqual(color.hexToDecimal('#00ffaa'), 65450); t.end(); }); + +test('hsvToRgb', t => { + rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 0}), {r: 0, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 123, s: 0.1234, v: 0}), {r: 0, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 1}), {r: 255, g: 255, b: 255}); + rgbSimilar(t, color.hsvToRgb({h: 321, s: 0, v: 1}), {r: 255, g: 255, b: 255}); + rgbSimilar(t, color.hsvToRgb({h: 0, s: 1, v: 1}), {r: 255, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 120, s: 1, v: 1}), {r: 0, g: 255, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 240, s: 1, v: 1}), {r: 0, g: 0, b: 255}); + t.end(); +}); + +test('rgbToHsv', t => { + hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 0}), {h: 0, s: 0, v: 0}); + hsvSimilar(t, color.rgbToHsv({r: 64, g: 64, b: 64}), {h: 0, s: 0, v: 0.25}); + hsvSimilar(t, color.rgbToHsv({r: 128, g: 128, b: 128}), {h: 0, s: 0, v: 0.5}); + hsvSimilar(t, color.rgbToHsv({r: 192, g: 192, b: 192}), {h: 0, s: 0, v: 0.75}); + hsvSimilar(t, color.rgbToHsv({r: 255, g: 255, b: 255}), {h: 0, s: 0, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 255, g: 0, b: 0}), {h: 0, s: 1, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 0, g: 255, b: 0}), {h: 120, s: 1, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 255}), {h: 240, s: 1, v: 1}); + t.end(); +}); + +test('mixRgb', t => { + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, -1), {r: 10, g: 20, b: 30}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0), {r: 10, g: 20, b: 30}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.25), {r: 15, g: 25, b: 35}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.5), {r: 20, g: 30, b: 40}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.75), {r: 25, g: 35, b: 45}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 1), {r: 30, g: 40, b: 50}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 2), {r: 30, g: 40, b: 50}); + t.end(); +}); diff --git a/test/unit/util_math.js b/test/unit/util_math.js index b198ef1d..d5a1c32f 100644 --- a/test/unit/util_math.js +++ b/test/unit/util_math.js @@ -1,7 +1,7 @@ -var test = require('tap').test; -var math = require('../../src/util/math-util'); +const test = require('tap').test; +const math = require('../../src/util/math-util'); -test('degToRad', function (t) { +test('degToRad', t => { t.strictEqual(math.degToRad(0), 0); t.strictEqual(math.degToRad(1), 0.017453292519943295); t.strictEqual(math.degToRad(180), Math.PI); @@ -10,7 +10,7 @@ test('degToRad', function (t) { t.end(); }); -test('radToDeg', function (t) { +test('radToDeg', t => { t.strictEqual(math.radToDeg(0), 0); t.strictEqual(math.radToDeg(1), 57.29577951308232); t.strictEqual(math.radToDeg(180), 10313.240312354817); @@ -19,7 +19,7 @@ test('radToDeg', function (t) { t.end(); }); -test('clamp', function (t) { +test('clamp', t => { t.strictEqual(math.clamp(0, 0, 10), 0); t.strictEqual(math.clamp(1, 0, 10), 1); t.strictEqual(math.clamp(-10, 0, 10), 0); @@ -27,10 +27,18 @@ test('clamp', function (t) { t.end(); }); -test('wrapClamp', function (t) { +test('wrapClamp', t => { t.strictEqual(math.wrapClamp(0, 0, 10), 0); t.strictEqual(math.wrapClamp(1, 0, 10), 1); t.strictEqual(math.wrapClamp(-10, 0, 10), 1); t.strictEqual(math.wrapClamp(100, 0, 10), 1); t.end(); }); + +test('tan', t => { + t.strictEqual(math.tan(90), Infinity); + t.strictEqual(math.tan(180), 0); + t.strictEqual(math.tan(-90), -Infinity); + t.strictEqual(math.tan(33), 0.6494075932); + t.end(); +}); diff --git a/test/unit/util_string.js b/test/unit/util_string.js new file mode 100644 index 00000000..aca20f9a --- /dev/null +++ b/test/unit/util_string.js @@ -0,0 +1,65 @@ +const test = require('tap').test; +const StringUtil = require('../../src/util/string-util'); + +test('splitFirst', t => { + t.deepEqual(StringUtil.splitFirst('asdf.1234', '.'), ['asdf', '1234']); + t.deepEqual(StringUtil.splitFirst('asdf.', '.'), ['asdf', '']); + t.deepEqual(StringUtil.splitFirst('.1234', '.'), ['', '1234']); + t.deepEqual(StringUtil.splitFirst('foo', '.'), ['foo', null]); + t.end(); +}); + +test('withoutTrailingDigits', t => { + t.strictEqual(StringUtil.withoutTrailingDigits('boeing747'), 'boeing'); + t.strictEqual(StringUtil.withoutTrailingDigits('boeing747 '), 'boeing747 '); + t.strictEqual(StringUtil.withoutTrailingDigits('boeing𝟨'), 'boeing𝟨'); + t.strictEqual(StringUtil.withoutTrailingDigits('boeing 747'), 'boeing '); + t.strictEqual(StringUtil.withoutTrailingDigits('747'), ''); + t.end(); +}); + +test('unusedName', t => { + t.strictEqual( + StringUtil.unusedName( + 'name', + ['not the same name'] + ), + 'name' + ); + t.strictEqual( + StringUtil.unusedName( + 'name', + ['name'] + ), + 'name2' + ); + t.strictEqual( + StringUtil.unusedName( + 'name', + ['name30'] + ), + 'name' + ); + t.strictEqual( + StringUtil.unusedName( + 'name', + ['name', 'name2'] + ), + 'name3' + ); + t.strictEqual( + StringUtil.unusedName( + 'name', + ['name', 'name3'] + ), + 'name2' + ); + t.strictEqual( + StringUtil.unusedName( + 'boeing747', + ['boeing747'] + ), + 'boeing2' // Yup, this matches scratch-flash... + ); + t.end(); +}); diff --git a/test/unit/util_timer.js b/test/unit/util_timer.js index 4780ec44..bf6d6b9a 100644 --- a/test/unit/util_timer.js +++ b/test/unit/util_timer.js @@ -1,8 +1,8 @@ -var test = require('tap').test; -var Timer = require('../../src/util/timer'); +const test = require('tap').test; +const Timer = require('../../src/util/timer'); -test('spec', function (t) { - var timer = new Timer(); +test('spec', t => { + const timer = new Timer(); t.type(Timer, 'function'); t.type(timer, 'object'); @@ -15,25 +15,25 @@ test('spec', function (t) { t.end(); }); -test('time', function (t) { - var timer = new Timer(); - var time = timer.time(); +test('time', t => { + const timer = new Timer(); + const time = timer.time(); t.ok(Date.now() >= time); t.end(); }); -test('start / timeElapsed', function (t) { - var timer = new Timer(); - var delay = 100; - var threshold = 1000 / 60; // 60 hz +test('start / timeElapsed', t => { + const timer = new Timer(); + const delay = 100; + const threshold = 1000 / 60; // 60 hz // Start timer timer.start(); // Wait and measure timer - setTimeout(function () { - var timeElapsed = timer.timeElapsed(); + setTimeout(() => { + const timeElapsed = timer.timeElapsed(); t.ok(timeElapsed >= 0); t.ok(timeElapsed >= (delay - threshold) && timeElapsed <= (delay + threshold)); diff --git a/test/unit/util_xml.js b/test/unit/util_xml.js index 1906a2ab..d22f37b6 100644 --- a/test/unit/util_xml.js +++ b/test/unit/util_xml.js @@ -1,9 +1,9 @@ -var test = require('tap').test; -var xml = require('../../src/util/xml-escape'); +const test = require('tap').test; +const xml = require('../../src/util/xml-escape'); -test('escape', function (t) { - var input = ''; - var output = '<foo bar="he & llo '"></foo>'; +test('escape', t => { + const input = ''; + const output = '<foo bar="he & llo '"></foo>'; t.strictEqual(xml(input), output); t.end(); }); diff --git a/test/unit/virtual-machine.js b/test/unit/virtual-machine.js new file mode 100644 index 00000000..8dd283cf --- /dev/null +++ b/test/unit/virtual-machine.js @@ -0,0 +1,363 @@ +const test = require('tap').test; +const VirtualMachine = require('../../src/virtual-machine.js'); +const Sprite = require('../../src/sprites/sprite.js'); + +test('renameSprite throws when there is no sprite with that id', t => { + const vm = new VirtualMachine(); + vm.runtime.getTargetById = () => null; + t.throws( + (() => vm.renameSprite('id', 'name')), + new Error('No target with the provided id.') + ); + t.end(); +}); + +test('renameSprite throws when used on a non-sprite target', t => { + const vm = new VirtualMachine(); + const fakeTarget = { + isSprite: () => false + }; + vm.runtime.getTargetById = () => (fakeTarget); + t.throws( + (() => vm.renameSprite('id', 'name')), + new Error('Cannot rename non-sprite targets.') + ); + t.end(); +}); + +test('renameSprite throws when there is no sprite for given target', t => { + const vm = new VirtualMachine(); + const fakeTarget = { + sprite: null, + isSprite: () => true + }; + vm.runtime.getTargetById = () => (fakeTarget); + t.throws( + (() => vm.renameSprite('id', 'name')), + new Error('No sprite associated with this target.') + ); + t.end(); +}); + +test('renameSprite sets the sprite name', t => { + const vm = new VirtualMachine(); + const fakeTarget = { + sprite: {name: 'original'}, + isSprite: () => true + }; + vm.runtime.getTargetById = () => (fakeTarget); + vm.renameSprite('id', 'not-original'); + t.equal(fakeTarget.sprite.name, 'not-original'); + t.end(); +}); + +test('renameSprite does not set sprite names to an empty string', t => { + const vm = new VirtualMachine(); + const fakeTarget = { + sprite: {name: 'original'}, + isSprite: () => true + }; + vm.runtime.getTargetById = () => (fakeTarget); + vm.renameSprite('id', ''); + t.equal(fakeTarget.sprite.name, 'original'); + t.end(); +}); + +test('renameSprite does not set sprite names to reserved names', t => { + const vm = new VirtualMachine(); + const fakeTarget = { + sprite: {name: 'original'}, + isSprite: () => true + }; + vm.runtime.getTargetById = () => (fakeTarget); + vm.renameSprite('id', '_mouse_'); + t.equal(fakeTarget.sprite.name, 'original'); + t.end(); +}); + +test('renameSprite increments from existing sprite names', t => { + const vm = new VirtualMachine(); + vm.emitTargetsUpdate = () => {}; + vm.runtime.targets = [{ + id: 'id1', + isSprite: () => true, + sprite: { + name: 'this name' + } + }, { + id: 'id2', + isSprite: () => true, + sprite: { + name: 'that name' + } + }]; + vm.renameSprite('id1', 'that name'); + t.equal(vm.runtime.targets[0].sprite.name, 'that name2'); + t.end(); +}); + +test('renameSprite does not increment when renaming to the same name', t => { + const vm = new VirtualMachine(); + vm.emitTargetsUpdate = () => {}; + vm.runtime.targets = [{ + id: 'id1', + isSprite: () => true, + sprite: { + name: 'this name' + } + }]; + vm.renameSprite('id1', 'this name'); + t.equal(vm.runtime.targets[0].sprite.name, 'this name'); + t.end(); +}); + +test('deleteSprite throws when used on a non-sprite target', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => false + }]; + t.throws( + (() => vm.deleteSprite('id')), + new Error('Cannot delete non-sprite targets.') + ); + t.end(); +}); + +test('deleteSprite throws when there is no sprite for the given target', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => true, + sprite: null + }]; + t.throws( + (() => vm.deleteSprite('id')), + new Error('No sprite associated with this target.') + ); + t.end(); +}); + +test('deleteSprite throws when there is no target with given id', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => true, + sprite: { + name: 'this name' + } + }]; + t.throws( + (() => vm.deleteSprite('id1')), + new Error('No target with the provided id.') + ); + t.end(); +}); + +test('deleteSprite deletes a sprite when given id is associated with a known sprite', t => { + const vm = new VirtualMachine(); + const spr = new Sprite(null, vm.runtime); + const currTarget = spr.createClone(); + + vm.runtime.targets = [currTarget]; + + t.equal(currTarget.sprite.clones.length, 1); + vm.deleteSprite(currTarget.id); + t.equal(currTarget.sprite.clones.length, 0); + t.end(); +}); + +// eslint-disable-next-line max-len +test('deleteSprite sets editing target as null when given sprite is current editing target, and the only target in the runtime', t => { + const vm = new VirtualMachine(); + const spr = new Sprite(null, vm.runtime); + const currTarget = spr.createClone(); + + vm.editingTarget = currTarget; + vm.runtime.targets = [currTarget]; + + vm.deleteSprite(currTarget.id); + + t.equal(vm.runtime.targets.length, 0); + t.equal(vm.editingTarget, null); + t.end(); +}); + +// eslint-disable-next-line max-len +test('deleteSprite updates editingTarget when sprite being deleted is current editing target, and there is another target in the runtime', t => { + const vm = new VirtualMachine(); + const spr1 = new Sprite(null, vm.runtime); + const spr2 = new Sprite(null, vm.runtime); + const currTarget = spr1.createClone(); + const otherTarget = spr2.createClone(); + + vm.emitWorkspaceUpdate = () => null; + + vm.runtime.targets = [currTarget, otherTarget]; + vm.editingTarget = currTarget; + + t.equal(vm.runtime.targets.length, 2); + vm.deleteSprite(currTarget.id); + t.equal(vm.runtime.targets.length, 1); + t.equal(vm.editingTarget.id, otherTarget.id); + + // now let's try them in the other order in the runtime.targets list + + // can't reuse deleted targets + const currTarget2 = spr1.createClone(); + const otherTarget2 = spr2.createClone(); + + vm.runtime.targets = [otherTarget2, currTarget2]; + vm.editingTarget = currTarget2; + + t.equal(vm.runtime.targets.length, 2); + vm.deleteSprite(currTarget2.id); + t.equal(vm.editingTarget.id, otherTarget2.id); + t.equal(vm.runtime.targets.length, 1); + + t.end(); +}); + +test('duplicateSprite throws when there is no target with given id', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => true, + sprite: { + name: 'this name' + } + }]; + t.throws( + (() => vm.duplicateSprite('id1')), + new Error('No target with the provided id') + ); + t.end(); +}); + +test('duplicateSprite throws when used on a non-sprite target', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => false + }]; + t.throws( + (() => vm.duplicateSprite('id')), + new Error('Cannot duplicate non-sprite targets.') + ); + t.end(); +}); + +test('duplicateSprite throws when there is no sprite for the given target', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [{ + id: 'id', + isSprite: () => true, + sprite: null + }]; + t.throws( + (() => vm.duplicateSprite('id')), + new Error('No sprite associated with this target.') + ); + t.end(); +}); + +test('duplicateSprite duplicates a sprite when given id is associated with known sprite', t => { + const vm = new VirtualMachine(); + const spr = new Sprite(null, vm.runtime); + const currTarget = spr.createClone(); + vm.editingTarget = currTarget; + + vm.emitWorkspaceUpdate = () => null; + + vm.runtime.targets = [currTarget]; + t.equal(vm.runtime.targets.length, 1); + vm.duplicateSprite(currTarget.id).then(() => { + t.equal(vm.runtime.targets.length, 2); + t.end(); + }); + +}); + +test('emitWorkspaceUpdate', t => { + const vm = new VirtualMachine(); + vm.runtime.targets = [ + { + isStage: true, + variables: { + global: { + toXML: () => 'global' + } + }, + blocks: { + toXML: () => 'blocks' + } + }, { + variables: { + unused: { + toXML: () => 'unused' + } + }, + blocks: { + toXML: () => 'blocks' + } + }, { + variables: { + local: { + toXML: () => 'local' + } + }, + blocks: { + toXML: () => 'blocks' + } + } + ]; + vm.editingTarget = vm.runtime.targets[2]; + + let xml = null; + vm.emit = (event, data) => (xml = data.xml); + vm.emitWorkspaceUpdate(); + t.notEqual(xml.indexOf('global'), -1); + t.notEqual(xml.indexOf('local'), -1); + t.equal(xml.indexOf('unused'), -1); + t.notEqual(xml.indexOf('blocks'), -1); + t.end(); +}); + +test('drag IO redirect', t => { + const vm = new VirtualMachine(); + const sprite1Info = []; + const sprite2Info = []; + vm.runtime.targets = [ + { + id: 'sprite1', + postSpriteInfo: data => sprite1Info.push(data) + }, { + id: 'sprite2', + postSpriteInfo: data => sprite2Info.push(data), + startDrag: () => {}, + stopDrag: () => {} + } + ]; + vm.editingTarget = vm.runtime.targets[0]; + // Stub emitWorkspace/TargetsUpdate, it needs data we don't care about here + vm.emitWorkspaceUpdate = () => null; + vm.emitTargetsUpdate = () => null; + + // postSpriteInfo should go to the editing target by default`` + vm.postSpriteInfo('sprite1 info'); + t.equal(sprite1Info[0], 'sprite1 info'); + + // postSprite info goes to the drag target if it exists + vm.startDrag('sprite2'); + vm.postSpriteInfo('sprite2 info'); + t.equal(sprite2Info[0], 'sprite2 info'); + + // stop drag should set the editing target + vm.stopDrag('sprite2'); + t.equal(vm.editingTarget.id, 'sprite2'); + + // Then postSpriteInfo should continue posting to the new editing target + vm.postSpriteInfo('sprite2 info 2'); + t.equal(sprite2Info[1], 'sprite2 info 2'); + t.end(); +}); diff --git a/webpack.config.js b/webpack.config.js index d46f55bf..0095d0dc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,69 +1,80 @@ -var CopyWebpackPlugin = require('copy-webpack-plugin'); -var defaultsDeep = require('lodash.defaultsdeep'); -var path = require('path'); -var webpack = require('webpack'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const defaultsDeep = require('lodash.defaultsdeep'); +const path = require('path'); +const webpack = require('webpack'); -var base = { +const base = { devServer: { - contentBase: path.resolve(__dirname, 'playground'), - host: '0.0.0.0' + contentBase: false, + host: '0.0.0.0', + port: process.env.PORT || 8073 + }, + devtool: 'cheap-module-source-map', + output: { + library: 'VirtualMachine', + filename: '[name].js' }, module: { - loaders: [ - { - test: /\.json$/, - loader: 'json-loader' + rules: [{ + test: /\.js$/, + loader: 'babel-loader', + include: path.resolve(__dirname, 'src'), + query: { + presets: ['es2015'] } - ] + }] }, - plugins: [ + plugins: process.env.NODE_ENV === 'production' ? [ new webpack.optimize.UglifyJsPlugin({ include: /\.min\.js$/, - minimize: true, - compress: { - warnings: false - } + minimize: true }) - ] + ] : [] }; module.exports = [ - // Web-compatible, playground + // Web-compatible defaultsDeep({}, base, { + target: 'web', entry: { - 'vm': './src/index.js', - 'vm.min': './src/index.js' + 'scratch-vm': './src/index.js', + 'scratch-vm.min': './src/index.js' }, output: { - path: __dirname, - filename: '[name].js' + libraryTarget: 'umd', + path: path.resolve('dist', 'web') }, module: { - loaders: base.module.loaders.concat([ + rules: base.module.rules.concat([ { test: require.resolve('./src/index.js'), - loader: 'expose?VirtualMachine' + loader: 'expose-loader?VirtualMachine' } ]) } }), - // Webpack-compatible + // Node-compatible defaultsDeep({}, base, { + target: 'node', entry: { - 'dist': './src/index.js' + 'scratch-vm': './src/index.js' }, - output: { - library: 'VirtualMachine', libraryTarget: 'commonjs2', - path: __dirname, - filename: '[name].js' - } + path: path.resolve('dist', 'node') + }, + plugins: base.plugins.concat([ + new CopyWebpackPlugin([{ + from: './src/extensions/scratch3_music/assets', + to: 'assets/scratch3_music' + }]) + ]) }), // Playground defaultsDeep({}, base, { + target: 'web', entry: { - 'vm': './src/index.js', + 'scratch-vm': './src/index.js', 'vendor': [ // FPS counter 'stats.js/build/stats.min.js', @@ -71,6 +82,10 @@ module.exports = [ 'highlightjs/highlight.pack.min.js', // Scratch Blocks 'scratch-blocks/dist/vertical.js', + // Audio + 'scratch-audio', + // Storage + 'scratch-storage', // Renderer 'scratch-render' ] @@ -80,26 +95,34 @@ module.exports = [ filename: '[name].js' }, module: { - loaders: base.module.loaders.concat([ + rules: base.module.rules.concat([ { test: require.resolve('./src/index.js'), - loader: 'expose?VirtualMachine' + loader: 'expose-loader?VirtualMachine' }, { test: require.resolve('stats.js/build/stats.min.js'), - loader: 'script' + loader: 'script-loader' }, { test: require.resolve('highlightjs/highlight.pack.min.js'), - loader: 'script' + loader: 'script-loader' }, { test: require.resolve('scratch-blocks/dist/vertical.js'), - loader: 'expose?Blockly' + loader: 'expose-loader?Blockly' + }, + { + test: require.resolve('scratch-audio'), + loader: 'expose-loader?AudioEngine' + }, + { + test: require.resolve('scratch-storage'), + loader: 'expose-loader?ScratchStorage' }, { test: require.resolve('scratch-render'), - loader: 'expose?RenderWebGL' + loader: 'expose-loader?RenderWebGL' } ]) }, @@ -110,8 +133,9 @@ module.exports = [ }, { from: 'node_modules/highlightjs/styles/zenburn.css' }, { - from: 'assets', - to: 'assets' + from: 'node_modules/scratch-storage/dist/web' + }, { + from: 'src/playground' }]) ]) })