Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
scratch-vm/.browserslistrc
Normal file
6
scratch-vm/.browserslistrc
Normal file
@@ -0,0 +1,6 @@
|
||||
chrome >= 70
|
||||
chromeandroid >= 70
|
||||
ios >= 12
|
||||
safari >= 12
|
||||
edge >= 17
|
||||
firefox >= 68
|
||||
15
scratch-vm/.editorconfig
Normal file
15
scratch-vm/.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{js,html}]
|
||||
indent_style = space
|
||||
|
||||
[.circleci/config.yml]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
5
scratch-vm/.eslintignore
Normal file
5
scratch-vm/.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
coverage/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
playground/*
|
||||
benchmark/*
|
||||
3
scratch-vm/.eslintrc.js
Normal file
3
scratch-vm/.eslintrc.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['scratch', 'scratch/node', 'scratch/es6']
|
||||
};
|
||||
38
scratch-vm/.gitattributes
vendored
Normal file
38
scratch-vm/.gitattributes
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
*.json5 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
|
||||
15
scratch-vm/.github/ISSUE_TEMPLATE.md
vendored
Normal file
15
scratch-vm/.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
### Expected Behavior
|
||||
|
||||
_Please describe what should happen_
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
_Describe what actually happens_
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
_Explain what someone needs to do in order to see what's described in *Actual behavior* above_
|
||||
|
||||
### Operating System and Browser
|
||||
|
||||
_e.g. Mac OS 10.11.6 Safari 10.0_
|
||||
15
scratch-vm/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
scratch-vm/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
### Resolves
|
||||
|
||||
_What Github issue does this resolve (please include link)?_
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
_Describe what this Pull Request does_
|
||||
|
||||
### Reason for Changes
|
||||
|
||||
_Explain why these changes should be made_
|
||||
|
||||
### Test Coverage
|
||||
|
||||
_Please show how you have added tests to cover your changes_
|
||||
48
scratch-vm/.github/workflows/deploy.yml
vendored
Normal file
48
scratch-vm/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Deploy playground
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
concurrency:
|
||||
group: "deploy"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build playground
|
||||
run: npm run build
|
||||
- name: Build docs
|
||||
run: npm run docs
|
||||
# We expect to see syntax errors from the old jsdoc cli not understanding some of our syntax
|
||||
# It will still generate what it can, so it's safe to ignore the error
|
||||
continue-on-error: true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./playground/
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
20
scratch-vm/.github/workflows/node.js.yml
vendored
Normal file
20
scratch-vm/.github/workflows/node.js.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
- run: npm run test
|
||||
22
scratch-vm/.gitignore
vendored
Normal file
22
scratch-vm/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Mac OS
|
||||
.DS_Store
|
||||
|
||||
# NPM
|
||||
/node_modules
|
||||
npm-*
|
||||
|
||||
# Testing
|
||||
/.nyc_output
|
||||
/coverage
|
||||
|
||||
# Editor
|
||||
/.idea
|
||||
/.vscode
|
||||
|
||||
# Build
|
||||
/dist
|
||||
/playground
|
||||
/benchmark
|
||||
|
||||
# Localization
|
||||
/translations
|
||||
20
scratch-vm/.jsdoc.json
Normal file
20
scratch-vm/.jsdoc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"plugins": ["plugins/markdown"],
|
||||
"templates": {
|
||||
"default": {
|
||||
"includeDate": false,
|
||||
"outputSourceFiles": false
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"include": ["src"]
|
||||
},
|
||||
"opts": {
|
||||
"destination": "playground/docs",
|
||||
"pedantic": true,
|
||||
"private": true,
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"template": "node_modules/docdash"
|
||||
}
|
||||
}
|
||||
19
scratch-vm/.npmignore
Normal file
19
scratch-vm/.npmignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# Development files
|
||||
.eslintrc.js
|
||||
/.editorconfig
|
||||
/.eslintignore
|
||||
/.gitattributes
|
||||
/.github
|
||||
/.travis.yml
|
||||
/.tx
|
||||
/test
|
||||
|
||||
# Build created files
|
||||
/playground
|
||||
|
||||
# Coverage created files
|
||||
/.nyc_output
|
||||
/coverage
|
||||
|
||||
# Exclude already built packages from testing with npm pack
|
||||
/scratch-vm-*.{tar,tgz}
|
||||
1
scratch-vm/.nvmrc
Normal file
1
scratch-vm/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16
|
||||
8
scratch-vm/.tx/config
Normal file
8
scratch-vm/.tx/config
Normal file
@@ -0,0 +1,8 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[scratch-editor.extensions]
|
||||
file_filter = translations/core/<lang>.json
|
||||
source_file = translations/core/en.json
|
||||
source_lang = en
|
||||
type = CHROME
|
||||
2621
scratch-vm/CHANGELOG.md
Normal file
2621
scratch-vm/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
373
scratch-vm/LICENSE
Normal file
373
scratch-vm/LICENSE
Normal file
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
171
scratch-vm/README.md
Normal file
171
scratch-vm/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
## TurboWarp/scratch-vm
|
||||
|
||||
Modified Scratch VM with a JIT compiler and more features.
|
||||
|
||||
This is a drop-in replacement for LLK/scratch-vm.
|
||||
|
||||
## Setup
|
||||
|
||||
See https://github.com/TurboWarp/scratch-gui/wiki/Getting-Started to setup the complete TurboWarp environment.
|
||||
|
||||
If you just want to play with the VM then it's the same process as upstream scratch-vm.
|
||||
|
||||
## Extension authors
|
||||
|
||||
If you only use the standard reporter, boolean, and command block types, everything should just work without any changes.
|
||||
|
||||
## Compiler Overview
|
||||
|
||||
For a high-level overview of how the compiler works, see https://docs.turbowarp.org/how
|
||||
|
||||
For more technical information, read the code in src/compiler.
|
||||
|
||||
## Public API
|
||||
|
||||
This section was too out of date to be useful. We hope to re-add it as some point.
|
||||
|
||||
## License
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
This program is based on [scratchfoundation/scratch-vm](https://github.com/scratchfoundation/scratch-vm), which is under this license:
|
||||
|
||||
```
|
||||
Copyright (c) 2016, Massachusetts Institute of Technology
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
<!--
|
||||
|
||||
## scratch-vm
|
||||
#### Scratch VM is a library for representing, running, and maintaining the state of computer programs written using [Scratch Blocks](https://github.com/scratchfoundation/scratch-blocks).
|
||||
|
||||
[](https://github.com/scratchfoundation/scratch-vm/actions/workflows/ci-cd.yml)
|
||||
|
||||
## Installation
|
||||
This requires you to have Git and Node.js installed.
|
||||
|
||||
To install as a dependency for your own application:
|
||||
```bash
|
||||
npm install scratch-vm
|
||||
```
|
||||
To set up a development environment to edit scratch-vm yourself:
|
||||
```bash
|
||||
git clone https://github.com/scratchfoundation/scratch-vm.git
|
||||
cd scratch-vm
|
||||
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). 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/scratchfoundation/scratch-gui/wiki/Getting-Started).
|
||||
|
||||
## Running the Development Server
|
||||
Open a Command Prompt or Terminal in the repository and run:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Playground
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
## Standalone Build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
```html
|
||||
<script src="/path/to/dist/web/scratch-vm.js"></script>
|
||||
<script>
|
||||
var vm = new window.VirtualMachine();
|
||||
// do things
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to include in a Node.js App
|
||||
For an extended setup example, check out the /src/playground directory, which includes a fully running VM instance.
|
||||
```js
|
||||
var VirtualMachine = require('scratch-vm');
|
||||
var vm = new VirtualMachine();
|
||||
|
||||
// Block events
|
||||
Scratch.workspace.addChangeListener(vm.blockListener);
|
||||
|
||||
// Run threads
|
||||
vm.start();
|
||||
```
|
||||
|
||||
## Abstract Syntax Tree
|
||||
|
||||
#### Overview
|
||||
The Virtual Machine constructs and maintains the state of an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) (AST) by listening to events emitted by the [scratch-blocks](https://github.com/scratchfoundation/scratch-blocks) workspace via the `blockListener`. Each target (code-running object, for example, a sprite) keeps an AST for its blocks. At any time, the current state of an AST can be viewed by inspecting the `vm.runtime.targets[...].blocks` object.
|
||||
|
||||
#### Anatomy of a Block
|
||||
The VM's block representation contains all the important information for execution and storage. Here's an example representing the "when key pressed" script on a workspace:
|
||||
```json
|
||||
{
|
||||
"_blocks": {
|
||||
"Q]PK~yJ@BTV8Y~FfISeo": {
|
||||
"id": "Q]PK~yJ@BTV8Y~FfISeo",
|
||||
"opcode": "event_whenkeypressed",
|
||||
"inputs": {
|
||||
},
|
||||
"fields": {
|
||||
"KEY_OPTION": {
|
||||
"name": "KEY_OPTION",
|
||||
"value": "space"
|
||||
}
|
||||
},
|
||||
"next": null,
|
||||
"topLevel": true,
|
||||
"parent": null,
|
||||
"shadow": false,
|
||||
"x": -69.333333333333,
|
||||
"y": 174
|
||||
}
|
||||
},
|
||||
"_scripts": [
|
||||
"Q]PK~yJ@BTV8Y~FfISeo"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run coverage
|
||||
```
|
||||
|
||||
## Publishing to GitHub Pages
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
This will push the currently built playground to the gh-pages branch of the
|
||||
currently tracked remote. If you would like to change where to push to, add
|
||||
a repo url argument:
|
||||
```bash
|
||||
npm run deploy -- -r <your repo url>
|
||||
```
|
||||
|
||||
## Donate
|
||||
We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you!
|
||||
|
||||
-->
|
||||
1
scratch-vm/TRADEMARK
Normal file
1
scratch-vm/TRADEMARK
Normal file
@@ -0,0 +1 @@
|
||||
The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
527
scratch-vm/docs/extensions.md
Normal file
527
scratch-vm/docs/extensions.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# Scratch 3.0 Extensions
|
||||
|
||||
This document describes technical topics related to Scratch 3.0 extension development, including the Scratch 3.0
|
||||
extension specification.
|
||||
|
||||
## Types of Extensions
|
||||
|
||||
There are four types of extensions that can define everything from the Scratch's core library (such as the "Looks" and
|
||||
"Operators" categories) to unofficial extensions that can be loaded from a remote URL.
|
||||
|
||||
**Scratch 3.0 does not yet support unofficial extensions.**
|
||||
|
||||
| | Core | Team | Official | Unofficial |
|
||||
| ------------------------------ | ---- | ---- | -------- | ---------- |
|
||||
| Developed by Scratch Team | √ | √ | O | X |
|
||||
| Maintained by Scratch Team | √ | √ | O | X |
|
||||
| Shown in Library | X | √ | √ | X |
|
||||
| Sandboxed | X | X | √ | √ |
|
||||
| Can save projects to community | √ | √ | √ | X |
|
||||
|
||||
## JavaScript Environment
|
||||
|
||||
Most Scratch 3.0 is written using JavaScript features not yet commonly supported by browsers. For compatibility we
|
||||
transpile the code to ES5 before publishing or deploying. Any extension included in the `scratch-vm` repository may
|
||||
use ES6+ features and may use `require` to reference other code within the `scratch-vm` repository.
|
||||
|
||||
Unofficial extensions must be self-contained. Authors of unofficial extensions are responsible for ensuring browser
|
||||
compatibility for those extensions, including transpiling if necessary.
|
||||
|
||||
## Translation
|
||||
|
||||
Scratch extensions use the [ICU message format](http://userguide.icu-project.org/formatparse/messages) to handle
|
||||
translation across languages. For **core, team, and official** extensions, the function `formatMessage` is used to
|
||||
wrap any ICU messages that need to be exported to the [Scratch Transifex group](https://www.transifex.com/llk/public/)
|
||||
for translation.
|
||||
|
||||
**All extensions** may additionally define a `translation_map` object within the `getInfo` function which can provide
|
||||
translations within an extension itself. The "Annotated Example" below provides a more complete illustration of how
|
||||
translation within an extension can be managed. **WARNING:** the `translation_map` feature is currently in the
|
||||
proposal phase and may change before implementation.
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
Scratch is designed to be fully backwards compatible. Because of this, block definitions and opcodes should *never*
|
||||
change in a way that could cause previously saved projects to fail to load or to act in unexpected / inconsistent
|
||||
ways.
|
||||
|
||||
## Defining an Extension
|
||||
|
||||
Scratch extensions are defined as a single Javascript class which accepts either a reference to the Scratch
|
||||
[VM](https://github.com/scratchfoundation/scratch-vm) runtime or a "runtime proxy" which handles communication with the Scratch VM
|
||||
across a well defined worker boundary (i.e. the sandbox).
|
||||
|
||||
```js
|
||||
class SomeBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* Store this for later communication with the Scratch VM runtime.
|
||||
* If this extension is running in a sandbox then `runtime` is an async proxy object.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
All extensions must define a function called `getInfo` which returns an object that contains the information needed to
|
||||
render both the blocks and the extension itself.
|
||||
|
||||
```js
|
||||
// Core, Team, and Official extensions can `require` VM code:
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
const BlockType = require('../../extension-support/block-type');
|
||||
|
||||
class SomeBlocks {
|
||||
// ...
|
||||
getInfo () {
|
||||
return {
|
||||
id: 'someBlocks',
|
||||
name: 'Some Blocks',
|
||||
blocks: [
|
||||
{
|
||||
opcode: 'myReporter',
|
||||
blockType: BlockType.REPORTER,
|
||||
text: 'letter [LETTER_NUM] of [TEXT]',
|
||||
arguments: {
|
||||
LETTER_NUM: {
|
||||
type: ArgumentType.STRING,
|
||||
defaultValue: '1'
|
||||
},
|
||||
TEXT: {
|
||||
type: ArgumentType.STRING,
|
||||
defaultValue: 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Finally the extension must define a function for any "opcode" defined in the blocks. For example:
|
||||
|
||||
```js
|
||||
class SomeBlocks {
|
||||
// ...
|
||||
myReporter (args) {
|
||||
return args.TEXT.charAt(args.LETTER_NUM);
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
### Block Arguments
|
||||
In addition to displaying text, blocks can have arguments in the form of slots to take other blocks getting plugged in, or dropdown menus to select an argument value from a list of possible values.
|
||||
|
||||
The possible types of block arguments are as follows:
|
||||
|
||||
- String - a string input, this is a type-able field which also accepts other reporter blocks to be plugged in
|
||||
- Number - an input similar to the string input, but the type-able values are constrained to numbers.
|
||||
- Angle - an input similar to the number input, but it has an additional UI to be able to pick an angle from a
|
||||
circular dial
|
||||
- Boolean - an input for a boolean (hexagonal shaped) reporter block. This field is not type-able.
|
||||
- Color - an input which displays a color swatch. This field has additional UI to pick a color by choosing values for the color's hue, saturation and brightness. Optionally, the defaultValue for the color picker can also be chosen if the extension developer wishes to display the same color every time the extension is added. If the defaultValue is left out, the default behavior of picking a random color when the extension is loaded will be used.
|
||||
- Matrix - an input which displays a 5 x 5 matrix of cells, where each cell can be filled in or clear.
|
||||
- Note - a numeric input which can select a musical note. This field has additional UI to select a note from a
|
||||
visual keyboard.
|
||||
- Image - an inline image displayed on a block. This is a special argument type in that it does not represent a value and does not accept other blocks to be plugged-in in place of this block field. See the section below about "Adding an Inline Image".
|
||||
|
||||
#### Adding an Inline Image
|
||||
In addition to specifying block arguments (an example of string arguments shown in the code snippet above),
|
||||
you can also specify an inline image for the block. You must include a dataURI for the image. If left unspecified, blank space will be allocated for the image and a warning will be logged in the console.
|
||||
You can optionally also specify `flipRTL`, a property indicating whether the image should be flipped horizontally when the editor has a right to left language selected as its locale. By default, the image is not flipped.
|
||||
|
||||
```js
|
||||
return {
|
||||
// ...
|
||||
blocks: [
|
||||
{
|
||||
//...
|
||||
arguments: {
|
||||
MY_IMAGE: {
|
||||
type: ArgumentType.IMAGE,
|
||||
dataURI: 'myImageData',
|
||||
alt: 'This is an image',
|
||||
flipRTL: true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
#### Defining a Menu
|
||||
|
||||
To display a drop-down menu for a block argument, specify the `menu` property of that argument and a matching item in
|
||||
the `menus` section of your extension's definition:
|
||||
|
||||
```js
|
||||
return {
|
||||
// ...
|
||||
blocks: [
|
||||
{
|
||||
// ...
|
||||
arguments: {
|
||||
FOO: {
|
||||
type: ArgumentType.NUMBER,
|
||||
menu: 'fooMenu'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
menus: {
|
||||
fooMenu: {
|
||||
items: ['a', 'b', 'c']
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The items in a menu may be specified with an array or with the name of a function which returns an array. The two
|
||||
simplest forms for menu definitions are:
|
||||
|
||||
```js
|
||||
getInfo () {
|
||||
return {
|
||||
menus: {
|
||||
staticMenu: ['static 1', 'static 2', 'static 3'],
|
||||
dynamicMenu: 'getDynamicMenuItems'
|
||||
}
|
||||
};
|
||||
}
|
||||
// this member function will be called each time the menu opens
|
||||
getDynamicMenuItems () {
|
||||
return ['dynamic 1', 'dynamic 2', 'dynamic 3'];
|
||||
}
|
||||
```
|
||||
|
||||
The examples above are shorthand for these equivalent definitions:
|
||||
|
||||
```js
|
||||
getInfo () {
|
||||
return {
|
||||
menus: {
|
||||
staticMenu: {
|
||||
items: ['static 1', 'static 2', 'static 3']
|
||||
},
|
||||
dynamicMenu: {
|
||||
items: 'getDynamicMenuItems'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
// this member function will be called each time the menu opens
|
||||
getDynamicMenuItems () {
|
||||
return ['dynamic 1', 'dynamic 2', 'dynamic 3'];
|
||||
}
|
||||
```
|
||||
|
||||
If a menu item needs a label that doesn't match its value -- for example, if the label needs to be displayed in the
|
||||
user's language but the value needs to stay constant -- the menu item may be an object instead of a string. This works
|
||||
for both static and dynamic menu items:
|
||||
|
||||
```js
|
||||
menus: {
|
||||
staticMenu: [
|
||||
{
|
||||
text: formatMessage(/* ... */),
|
||||
value: 42
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
##### Accepting reporters ("droppable" menus)
|
||||
|
||||
By default it is not possible to specify the value of a dropdown menu by inserting a reporter block. While we
|
||||
encourage extension authors to make their menus accept reporters when possible, doing so requires careful
|
||||
consideration to avoid confusion and frustration on the part of those using the extension.
|
||||
|
||||
A few of these considerations include:
|
||||
|
||||
* The valid values for the menu should not change when the user changes the Scratch language setting.
|
||||
* In particular, changing languages should never break a working project.
|
||||
* The average Scratch user should be able to figure out the valid values for this input without referring to extension
|
||||
documentation.
|
||||
* One way to ensure this is to make an item's text match or include the item's value. For example, the official Music
|
||||
extension contains menu items with names like "(1) Piano" with value 1, "(8) Cello" with value 8, and so on.
|
||||
* The block should accept any value as input, even "invalid" values.
|
||||
* Scratch has no concept of a runtime error!
|
||||
* For a command block, sometimes the best option is to do nothing.
|
||||
* For a reporter, returning zero or the empty string might make sense.
|
||||
* The block should be forgiving in its interpretation of inputs.
|
||||
* For example, if the block expects a string and receives a number it may make sense to interpret the number as a
|
||||
string instead of treating it as invalid input.
|
||||
|
||||
The `acceptReporters` flag indicates that the user can drop a reporter onto the menu input:
|
||||
|
||||
```js
|
||||
menus: {
|
||||
staticMenu: {
|
||||
acceptReporters: true,
|
||||
items: [/*...*/]
|
||||
},
|
||||
dynamicMenu: {
|
||||
acceptReporters: true,
|
||||
items: 'getDynamicMenuItems'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Annotated Example
|
||||
|
||||
```js
|
||||
// Core, Team, and Official extensions can `require` VM code:
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
const BlockType = require('../../extension-support/block-type');
|
||||
const TargetType = require('../../extension-support/target-type');
|
||||
|
||||
// ...or VM dependencies:
|
||||
const formatMessage = require('format-message');
|
||||
|
||||
// Core, Team, and Official extension classes should be registered statically with the Extension Manager.
|
||||
// See: scratch-vm/src/extension-support/extension-manager.js
|
||||
class SomeBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* Store this for later communication with the Scratch VM runtime.
|
||||
* If this extension is running in a sandbox then `runtime` is an async proxy object.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {object} This extension's metadata.
|
||||
*/
|
||||
getInfo () {
|
||||
return {
|
||||
// Required: the machine-readable name of this extension.
|
||||
// Will be used as the extension's namespace.
|
||||
// Allowed characters are those matching the regular expression [\w-]: A-Z, a-z, 0-9, and hyphen ("-").
|
||||
id: 'someBlocks',
|
||||
|
||||
// Core extensions only: override the default extension block colors.
|
||||
color1: '#FF8C1A',
|
||||
color2: '#DB6E00',
|
||||
|
||||
// Optional: the human-readable name of this extension as string.
|
||||
// This and any other string to be displayed in the Scratch UI may either be
|
||||
// a string or a call to `formatMessage`; a plain string will not be
|
||||
// translated whereas a call to `formatMessage` will connect the string
|
||||
// to the translation map (see below). The `formatMessage` call is
|
||||
// similar to `formatMessage` from `react-intl` in form, but will actually
|
||||
// call some extension support code to do its magic. For example, we will
|
||||
// internally namespace the messages such that two extensions could have
|
||||
// messages with the same ID without colliding.
|
||||
// See also: https://github.com/yahoo/react-intl/wiki/API#formatmessage
|
||||
name: formatMessage({
|
||||
id: 'extensionName',
|
||||
defaultMessage: 'Some Blocks',
|
||||
description: 'The name of the "Some Blocks" extension'
|
||||
}),
|
||||
|
||||
// Optional: URI for a block icon, to display at the edge of each block for this
|
||||
// extension. Data URI OK.
|
||||
// TODO: what file types are OK? All web images? Just PNG?
|
||||
blockIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',
|
||||
|
||||
// Optional: URI for an icon to be displayed in the blocks category menu.
|
||||
// If not present, the menu will display the block icon, if one is present.
|
||||
// Otherwise, the category menu shows its default filled circle.
|
||||
// Data URI OK.
|
||||
// TODO: what file types are OK? All web images? Just PNG?
|
||||
menuIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',
|
||||
|
||||
// Optional: Link to documentation content for this extension.
|
||||
// If not present, offer no link.
|
||||
docsURI: 'https://....',
|
||||
|
||||
// Required: the list of blocks implemented by this extension,
|
||||
// in the order intended for display.
|
||||
blocks: [
|
||||
{
|
||||
// Required: the machine-readable name of this operation.
|
||||
// This will appear in project JSON.
|
||||
opcode: 'myReporter', // becomes 'someBlocks.myReporter'
|
||||
|
||||
// Required: the kind of block we're defining, from a predefined list.
|
||||
// Fully supported block types:
|
||||
// BlockType.BOOLEAN - same as REPORTER but returns a Boolean value
|
||||
// BlockType.COMMAND - a normal command block, like "move {} steps"
|
||||
// BlockType.HAT - starts a stack if its value changes from falsy to truthy ("edge triggered")
|
||||
// BlockType.REPORTER - returns a value, like "direction"
|
||||
// Block types in development or for internal use only:
|
||||
// BlockType.BUTTON - place a button in the block palette
|
||||
// BlockType.CONDITIONAL - control flow, like "if {}" or "if {} else {}"
|
||||
// A CONDITIONAL block may return the one-based index of a branch to
|
||||
// run, or it may return zero/falsy to run no branch.
|
||||
// BlockType.EVENT - starts a stack in response to an event (full spec TBD)
|
||||
// BlockType.LOOP - control flow, like "repeat {} {}" or "forever {}"
|
||||
// A LOOP block is like a CONDITIONAL block with two differences:
|
||||
// - the block is assumed to have exactly one child branch, and
|
||||
// - each time a child branch finishes, the loop block is called again.
|
||||
blockType: BlockType.REPORTER,
|
||||
|
||||
// Required for CONDITIONAL blocks, ignored for others: the number of
|
||||
// child branches this block controls. An "if" or "repeat" block would
|
||||
// specify a branch count of 1; an "if-else" block would specify a
|
||||
// branch count of 2.
|
||||
// TODO: should we support dynamic branch count for "switch"-likes?
|
||||
branchCount: 0,
|
||||
|
||||
// Optional, default false: whether or not this block ends a stack.
|
||||
// The "forever" and "stop all" blocks would specify true here.
|
||||
terminal: true,
|
||||
|
||||
// Optional, default false: whether or not to block all threads while
|
||||
// this block is busy. This is for things like the "touching color"
|
||||
// block in compatibility mode, and is only needed if the VM runs in a
|
||||
// worker. We might even consider omitting it from extension docs...
|
||||
blockAllThreads: false,
|
||||
|
||||
// Required: the human-readable text on this block, including argument
|
||||
// placeholders. Argument placeholders should be in [MACRO_CASE] and
|
||||
// must be [ENCLOSED_WITHIN_SQUARE_BRACKETS].
|
||||
text: formatMessage({
|
||||
id: 'myReporter',
|
||||
defaultMessage: 'letter [LETTER_NUM] of [TEXT]',
|
||||
description: 'Label on the "myReporter" block'
|
||||
}),
|
||||
|
||||
// Required: describe each argument.
|
||||
// Argument order may change during translation, so arguments are
|
||||
// identified by their placeholder name. In those situations where
|
||||
// arguments must be ordered or assigned an ordinal, such as interaction
|
||||
// with Scratch Blocks, arguments are ordered as they are in the default
|
||||
// translation (probably English).
|
||||
arguments: {
|
||||
// Required: the ID of the argument, which will be the name in the
|
||||
// args object passed to the implementation function.
|
||||
LETTER_NUM: {
|
||||
// Required: type of the argument / shape of the block input
|
||||
type: ArgumentType.NUMBER,
|
||||
|
||||
// Optional: the default value of the argument
|
||||
default: 1
|
||||
},
|
||||
|
||||
// Required: the ID of the argument, which will be the name in the
|
||||
// args object passed to the implementation function.
|
||||
TEXT: {
|
||||
// Required: type of the argument / shape of the block input
|
||||
type: ArgumentType.STRING,
|
||||
|
||||
// Optional: the default value of the argument
|
||||
default: formatMessage({
|
||||
id: 'myReporter.TEXT_default',
|
||||
defaultMessage: 'text',
|
||||
description: 'Default for "TEXT" argument of "someBlocks.myReporter"'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: the function implementing this block.
|
||||
// If absent, assume `func` is the same as `opcode`.
|
||||
func: 'myReporter',
|
||||
|
||||
// Optional: list of target types for which this block should appear.
|
||||
// If absent, assume it applies to all builtin targets -- that is:
|
||||
// [TargetType.SPRITE, TargetType.STAGE]
|
||||
filter: [TargetType.SPRITE]
|
||||
},
|
||||
{
|
||||
// Another block...
|
||||
}
|
||||
],
|
||||
|
||||
// Optional: define extension-specific menus here.
|
||||
menus: {
|
||||
// Required: an identifier for this menu, unique within this extension.
|
||||
menuA: [
|
||||
// Static menu: list items which should appear in the menu.
|
||||
{
|
||||
// Required: the value of the menu item when it is chosen.
|
||||
value: 'itemId1',
|
||||
|
||||
// Optional: the human-readable label for this item.
|
||||
// Use `value` as the text if this is absent.
|
||||
text: formatMessage({
|
||||
id: 'menuA_item1',
|
||||
defaultMessage: 'Item One',
|
||||
description: 'Label for item 1 of menu A in "Some Blocks" extension'
|
||||
})
|
||||
},
|
||||
|
||||
// The simplest form of a list item is a string which will be used as
|
||||
// both value and text.
|
||||
'itemId2'
|
||||
],
|
||||
|
||||
// Dynamic menu: returns an array as above.
|
||||
// Called each time the menu is opened.
|
||||
menuB: 'getItemsForMenuB',
|
||||
|
||||
// The examples above are shorthand for setting only the `items` property in this full form:
|
||||
menuC: {
|
||||
// This flag makes a "droppable" menu: the menu will allow dropping a reporter in for the input.
|
||||
acceptReporters: true,
|
||||
|
||||
// The `item` property may be an array or function name as in previous menu examples.
|
||||
items: [/*...*/] || 'getItemsForMenuC'
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: translations (UNSTABLE - NOT YET SUPPORTED)
|
||||
translation_map: {
|
||||
de: {
|
||||
'extensionName': 'Einige Blöcke',
|
||||
'myReporter': 'Buchstabe [LETTER_NUM] von [TEXT]',
|
||||
'myReporter.TEXT_default': 'Text',
|
||||
'menuA_item1': 'Artikel eins',
|
||||
|
||||
// Dynamic menus can be translated too
|
||||
'menuB_example': 'Beispiel',
|
||||
|
||||
// This message contains ICU placeholders (see `myReporter()` below)
|
||||
'myReporter.result': 'Buchstabe {LETTER_NUM} von {TEXT} ist {LETTER}.'
|
||||
},
|
||||
it: {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Implement myReporter.
|
||||
* @param {object} args - the block's arguments.
|
||||
* @property {string} MY_ARG - the string value of the argument.
|
||||
* @returns {string} a string which includes the block argument value.
|
||||
*/
|
||||
myReporter (args) {
|
||||
// This message contains ICU placeholders, not Scratch placeholders
|
||||
const message = formatMessage({
|
||||
id: 'myReporter.result',
|
||||
defaultMessage: 'Letter {LETTER_NUM} of {TEXT} is {LETTER}.',
|
||||
description: 'The text template for the "myReporter" block result'
|
||||
});
|
||||
|
||||
// Note: this implementation is not Unicode-clean; it's just here as an example.
|
||||
const result = args.TEXT.charAt(args.LETTER_NUM);
|
||||
|
||||
return message.format({
|
||||
LETTER_NUM: args.LETTER_NUM,
|
||||
TEXT: args.TEXT,
|
||||
LETTER: result
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
20229
scratch-vm/package-lock.json
generated
Normal file
20229
scratch-vm/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
scratch-vm/package.json
Normal file
97
scratch-vm/package.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "scratch-vm",
|
||||
"version": "2.1.46",
|
||||
"description": "Virtual Machine for Scratch 3.0",
|
||||
"author": "Massachusetts Institute of Technology",
|
||||
"license": "MPL-2.0",
|
||||
"homepage": "https://github.com/scratchfoundation/scratch-vm#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/scratchfoundation/scratch-vm.git"
|
||||
},
|
||||
"main": "./src/index.js",
|
||||
"browser": "./src/index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --progress --colors --bail",
|
||||
"coverage": "tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov",
|
||||
"docs": "jsdoc -c .jsdoc.json",
|
||||
"i18n:src": "mkdirp translations/core && format-message extract --out-file translations/core/en.json src/extensions/**/index.js",
|
||||
"i18n:push": "tx-push-src scratch-editor extensions translations/core/en.json",
|
||||
"lint": "eslint .",
|
||||
"prepublish": "in-publish && npm run build || not-in-publish",
|
||||
"start": "webpack-dev-server",
|
||||
"tap": "tap ./test/{unit,integration}/*.js",
|
||||
"tap:unit": "tap ./test/unit/*.js",
|
||||
"tap:integration": "tap ./test/integration/*.js",
|
||||
"test": "npm run lint && npm run tap",
|
||||
"watch": "webpack --progress --colors --watch",
|
||||
"version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\""
|
||||
},
|
||||
"tap": {
|
||||
"branches": 60,
|
||||
"functions": 70,
|
||||
"lines": 70,
|
||||
"statements": 70
|
||||
},
|
||||
"dependencies": {
|
||||
"@turbowarp/json": "^0.1.2",
|
||||
"@turbowarp/jszip": "^3.11.0",
|
||||
"@turbowarp/nanolog": "^0.2.0",
|
||||
"@vernier/godirect": "1.5.0",
|
||||
"arraybuffer-loader": "^1.0.6",
|
||||
"atob": "2.1.2",
|
||||
"btoa": "1.2.1",
|
||||
"canvas-toBlob": "1.0.0",
|
||||
"decode-html": "2.0.0",
|
||||
"diff-match-patch": "1.0.4",
|
||||
"format-message": "6.2.1",
|
||||
"htmlparser2": "3.10.0",
|
||||
"immutable": "3.8.2",
|
||||
"scratch-parser": "github:TurboWarp/scratch-parser#master",
|
||||
"scratch-sb1-converter": "0.2.7",
|
||||
"scratch-translate-extension-languages": "0.0.20191118205314",
|
||||
"text-encoding": "0.7.0",
|
||||
"uuid": "8.3.2",
|
||||
"worker-loader": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@turbowarp/scratch-svg-renderer": "^1.0.0-202312300007-62fe825"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.13.10",
|
||||
"@babel/preset-env": "7.14.8",
|
||||
"adm-zip": "0.4.11",
|
||||
"babel-eslint": "10.1.0",
|
||||
"babel-loader": "8.2.2",
|
||||
"callsite": "1.0.0",
|
||||
"copy-webpack-plugin": "4.5.4",
|
||||
"docdash": "1.2.0",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-config-scratch": "9.0.3",
|
||||
"expose-loader": "0.7.5",
|
||||
"file-loader": "2.0.0",
|
||||
"format-message-cli": "6.2.0",
|
||||
"husky": "^9.1.6",
|
||||
"in-publish": "2.0.1",
|
||||
"js-md5": "0.7.3",
|
||||
"jsdoc": "3.6.6",
|
||||
"json": "^9.0.4",
|
||||
"lodash.defaultsdeep": "4.6.1",
|
||||
"pngjs": "3.3.3",
|
||||
"scratch-audio": "0.1.0-prerelease.20231221012053",
|
||||
"scratch-blocks": "0.1.0-prerelease.20230527085947",
|
||||
"scratch-l10n": "3.16.20231222031921",
|
||||
"scratch-render": "0.1.0-prerelease.20231220210403",
|
||||
"scratch-render-fonts": "1.0.0-prerelease.20231017225105",
|
||||
"scratch-storage": "2.3.1",
|
||||
"script-loader": "0.7.2",
|
||||
"stats.js": "0.17.0",
|
||||
"tap": "16.2.0",
|
||||
"tiny-worker": "2.3.0",
|
||||
"uglifyjs-webpack-plugin": "1.2.7",
|
||||
"webpack": "4.47.0",
|
||||
"webpack-cli": "3.1.0",
|
||||
"webpack-dev-server": "3.11.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
13
scratch-vm/release.config.js
Normal file
13
scratch-vm/release.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
extends: 'scratch-semantic-release-config',
|
||||
branches: [
|
||||
{
|
||||
name: 'develop'
|
||||
// default channel
|
||||
},
|
||||
{
|
||||
name: 'hotfix/*',
|
||||
channel: 'hotfix'
|
||||
}
|
||||
]
|
||||
};
|
||||
7
scratch-vm/renovate.json5
Normal file
7
scratch-vm/renovate.json5
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
|
||||
"extends": [
|
||||
"github>LLK/scratch-renovate-config:conservative"
|
||||
]
|
||||
}
|
||||
10
scratch-vm/src/.eslintrc.js
Normal file
10
scratch-vm/src/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['scratch', 'scratch/es6'],
|
||||
env: {
|
||||
browser: true
|
||||
},
|
||||
rules: {
|
||||
'valid-jsdoc': 'off'
|
||||
}
|
||||
};
|
||||
206
scratch-vm/src/blocks/scratch3_control.js
Normal file
206
scratch-vm/src/blocks/scratch3_control.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const Cast = require('../util/cast');
|
||||
|
||||
class Scratch3ControlBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
/**
|
||||
* The "counter" block value. For compatibility with 2.0.
|
||||
* @type {number}
|
||||
*/
|
||||
this._counter = 0; // used by compiler
|
||||
|
||||
this.runtime.on('RUNTIME_DISPOSED', this.clearCounter.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
control_repeat: this.repeat,
|
||||
control_repeat_until: this.repeatUntil,
|
||||
control_while: this.repeatWhile,
|
||||
control_for_each: this.forEach,
|
||||
control_forever: this.forever,
|
||||
control_wait: this.wait,
|
||||
control_wait_until: this.waitUntil,
|
||||
control_if: this.if,
|
||||
control_if_else: this.ifElse,
|
||||
control_stop: this.stop,
|
||||
control_create_clone_of: this.createClone,
|
||||
control_delete_this_clone: this.deleteClone,
|
||||
control_get_counter: this.getCounter,
|
||||
control_incr_counter: this.incrCounter,
|
||||
control_clear_counter: this.clearCounter,
|
||||
control_all_at_once: this.allAtOnce
|
||||
};
|
||||
}
|
||||
|
||||
getHats () {
|
||||
return {
|
||||
control_start_as_clone: {
|
||||
restartExistingThreads: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
repeat (args, util) {
|
||||
const times = Math.round(Cast.toNumber(args.TIMES));
|
||||
// Initialize loop
|
||||
if (typeof util.stackFrame.loopCounter === 'undefined') {
|
||||
util.stackFrame.loopCounter = times;
|
||||
}
|
||||
// Only execute once per frame.
|
||||
// When the branch finishes, `repeat` will be executed again and
|
||||
// the second branch will be taken, yielding for the rest of the frame.
|
||||
// Decrease counter
|
||||
util.stackFrame.loopCounter--;
|
||||
// If we still have some left, start the branch.
|
||||
if (util.stackFrame.loopCounter >= 0) {
|
||||
util.startBranch(1, true);
|
||||
}
|
||||
}
|
||||
|
||||
repeatUntil (args, util) {
|
||||
const condition = Cast.toBoolean(args.CONDITION);
|
||||
// If the condition is false (repeat UNTIL), start the branch.
|
||||
if (!condition) {
|
||||
util.startBranch(1, true);
|
||||
}
|
||||
}
|
||||
|
||||
repeatWhile (args, util) {
|
||||
const condition = Cast.toBoolean(args.CONDITION);
|
||||
// If the condition is true (repeat WHILE), start the branch.
|
||||
if (condition) {
|
||||
util.startBranch(1, true);
|
||||
}
|
||||
}
|
||||
|
||||
forEach (args, util) {
|
||||
const variable = util.target.lookupOrCreateVariable(
|
||||
args.VARIABLE.id, args.VARIABLE.name);
|
||||
|
||||
if (typeof util.stackFrame.index === 'undefined') {
|
||||
util.stackFrame.index = 0;
|
||||
}
|
||||
|
||||
if (util.stackFrame.index < Number(args.VALUE)) {
|
||||
util.stackFrame.index++;
|
||||
variable.value = util.stackFrame.index;
|
||||
util.startBranch(1, true);
|
||||
}
|
||||
}
|
||||
|
||||
waitUntil (args, util) {
|
||||
const condition = Cast.toBoolean(args.CONDITION);
|
||||
if (!condition) {
|
||||
util.yield();
|
||||
}
|
||||
}
|
||||
|
||||
forever (args, util) {
|
||||
util.startBranch(1, true);
|
||||
}
|
||||
|
||||
wait (args, util) {
|
||||
if (util.stackTimerNeedsInit()) {
|
||||
const duration = Math.max(0, 1000 * Cast.toNumber(args.DURATION));
|
||||
|
||||
util.startStackTimer(duration);
|
||||
this.runtime.requestRedraw();
|
||||
util.yield();
|
||||
} else if (!util.stackTimerFinished()) {
|
||||
util.yield();
|
||||
}
|
||||
}
|
||||
|
||||
if (args, util) {
|
||||
const condition = Cast.toBoolean(args.CONDITION);
|
||||
if (condition) {
|
||||
util.startBranch(1, false);
|
||||
}
|
||||
}
|
||||
|
||||
ifElse (args, util) {
|
||||
const condition = Cast.toBoolean(args.CONDITION);
|
||||
if (condition) {
|
||||
util.startBranch(1, false);
|
||||
} else {
|
||||
util.startBranch(2, false);
|
||||
}
|
||||
}
|
||||
|
||||
stop (args, util) {
|
||||
const option = args.STOP_OPTION;
|
||||
if (option === 'all') {
|
||||
util.stopAll();
|
||||
} else if (option === 'other scripts in sprite' ||
|
||||
option === 'other scripts in stage') {
|
||||
util.stopOtherTargetThreads();
|
||||
} else if (option === 'this script') {
|
||||
util.stopThisScript();
|
||||
}
|
||||
}
|
||||
|
||||
createClone (args, util) {
|
||||
this._createClone(Cast.toString(args.CLONE_OPTION), util.target);
|
||||
}
|
||||
_createClone (cloneOption, target) { // used by compiler
|
||||
// Set clone target
|
||||
let cloneTarget;
|
||||
if (cloneOption === '_myself_') {
|
||||
cloneTarget = target;
|
||||
} else {
|
||||
cloneTarget = this.runtime.getSpriteTargetByName(cloneOption);
|
||||
}
|
||||
|
||||
// If clone target is not found, return
|
||||
if (!cloneTarget) return;
|
||||
|
||||
// Create clone
|
||||
const newClone = cloneTarget.makeClone();
|
||||
if (newClone) {
|
||||
this.runtime.addTarget(newClone);
|
||||
|
||||
// Place behind the original target.
|
||||
newClone.goBehindOther(cloneTarget);
|
||||
}
|
||||
}
|
||||
|
||||
deleteClone (args, util) {
|
||||
if (util.target.isOriginal) return;
|
||||
this.runtime.disposeTarget(util.target);
|
||||
this.runtime.stopForTarget(util.target);
|
||||
}
|
||||
|
||||
getCounter () {
|
||||
return this._counter;
|
||||
}
|
||||
|
||||
clearCounter () {
|
||||
this._counter = 0;
|
||||
}
|
||||
|
||||
incrCounter () {
|
||||
this._counter++;
|
||||
}
|
||||
|
||||
allAtOnce (args, util) {
|
||||
// Since the "all at once" block is implemented for compatiblity with
|
||||
// Scratch 2.0 projects, it behaves the same way it did in 2.0, which
|
||||
// is to simply run the contained script (like "if 1 = 1").
|
||||
// (In early versions of Scratch 2.0, it would work the same way as
|
||||
// "run without screen refresh" custom blocks do now, but this was
|
||||
// removed before the release of 2.0.)
|
||||
util.startBranch(1, false);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3ControlBlocks;
|
||||
69
scratch-vm/src/blocks/scratch3_core_example.js
Normal file
69
scratch-vm/src/blocks/scratch3_core_example.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const BlockType = require('../extension-support/block-type');
|
||||
const ArgumentType = require('../extension-support/argument-type');
|
||||
|
||||
/* eslint-disable-next-line max-len */
|
||||
const blockIconURI = 'data:image/svg+xml,%3Csvg id="rotate-counter-clockwise" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%233d79cc;%7D.cls-2%7Bfill:%23fff;%7D%3C/style%3E%3C/defs%3E%3Ctitle%3Erotate-counter-clockwise%3C/title%3E%3Cpath class="cls-1" d="M22.68,12.2a1.6,1.6,0,0,1-1.27.63H13.72a1.59,1.59,0,0,1-1.16-2.58l1.12-1.41a4.82,4.82,0,0,0-3.14-.77,4.31,4.31,0,0,0-2,.8,4.25,4.25,0,0,0-1.34,1.73,5.06,5.06,0,0,0,.54,4.62A5.58,5.58,0,0,0,12,17.74h0a2.26,2.26,0,0,1-.16,4.52A10.25,10.25,0,0,1,3.74,18,10.14,10.14,0,0,1,2.25,8.78,9.7,9.7,0,0,1,5.08,4.64,9.92,9.92,0,0,1,9.66,2.5a10.66,10.66,0,0,1,7.72,1.68l1.08-1.35a1.57,1.57,0,0,1,1.24-.6,1.6,1.6,0,0,1,1.54,1.21l1.7,7.37A1.57,1.57,0,0,1,22.68,12.2Z"/%3E%3Cpath class="cls-2" d="M21.38,11.83H13.77a.59.59,0,0,1-.43-1l1.75-2.19a5.9,5.9,0,0,0-4.7-1.58,5.07,5.07,0,0,0-4.11,3.17A6,6,0,0,0,7,15.77a6.51,6.51,0,0,0,5,2.92,1.31,1.31,0,0,1-.08,2.62,9.3,9.3,0,0,1-7.35-3.82A9.16,9.16,0,0,1,3.17,9.12,8.51,8.51,0,0,1,5.71,5.4,8.76,8.76,0,0,1,9.82,3.48a9.71,9.71,0,0,1,7.75,2.07l1.67-2.1a.59.59,0,0,1,1,.21L22,11.08A.59.59,0,0,1,21.38,11.83Z"/%3E%3C/svg%3E';
|
||||
|
||||
/**
|
||||
* An example core block implemented using the extension spec.
|
||||
* This is not loaded as part of the core blocks in the VM but it is provided
|
||||
* and used as part of tests.
|
||||
*/
|
||||
class Scratch3CoreExample {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} metadata for this extension and its blocks.
|
||||
*/
|
||||
getInfo () {
|
||||
return {
|
||||
id: 'coreExample',
|
||||
name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example.
|
||||
blocks: [
|
||||
{
|
||||
func: 'MAKE_A_VARIABLE',
|
||||
blockType: BlockType.BUTTON,
|
||||
text: 'make a variable (CoreEx)'
|
||||
},
|
||||
{
|
||||
opcode: 'exampleOpcode',
|
||||
blockType: BlockType.REPORTER,
|
||||
text: 'example block'
|
||||
},
|
||||
{
|
||||
opcode: 'exampleWithInlineImage',
|
||||
blockType: BlockType.COMMAND,
|
||||
text: 'block with image [CLOCKWISE] inline',
|
||||
arguments: {
|
||||
CLOCKWISE: {
|
||||
type: ArgumentType.IMAGE,
|
||||
dataURI: blockIconURI
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Example opcode just returns the name of the stage target.
|
||||
* @returns {string} The name of the first target in the project.
|
||||
*/
|
||||
exampleOpcode () {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
return stage ? stage.getName() : 'no stage yet';
|
||||
}
|
||||
|
||||
exampleWithInlineImage () {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Scratch3CoreExample;
|
||||
240
scratch-vm/src/blocks/scratch3_data.js
Normal file
240
scratch-vm/src/blocks/scratch3_data.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const Cast = require('../util/cast');
|
||||
|
||||
class Scratch3DataBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
data_variable: this.getVariable,
|
||||
data_setvariableto: this.setVariableTo,
|
||||
data_changevariableby: this.changeVariableBy,
|
||||
data_hidevariable: this.hideVariable,
|
||||
data_showvariable: this.showVariable,
|
||||
data_listcontents: this.getListContents,
|
||||
data_addtolist: this.addToList,
|
||||
data_deleteoflist: this.deleteOfList,
|
||||
data_deletealloflist: this.deleteAllOfList,
|
||||
data_insertatlist: this.insertAtList,
|
||||
data_replaceitemoflist: this.replaceItemOfList,
|
||||
data_itemoflist: this.getItemOfList,
|
||||
data_itemnumoflist: this.getItemNumOfList,
|
||||
data_lengthoflist: this.lengthOfList,
|
||||
data_listcontainsitem: this.listContainsItem,
|
||||
data_hidelist: this.hideList,
|
||||
data_showlist: this.showList
|
||||
};
|
||||
}
|
||||
|
||||
getVariable (args, util) {
|
||||
const variable = util.target.lookupOrCreateVariable(
|
||||
args.VARIABLE.id, args.VARIABLE.name);
|
||||
return variable.value;
|
||||
}
|
||||
|
||||
setVariableTo (args, util) {
|
||||
const variable = util.target.lookupOrCreateVariable(
|
||||
args.VARIABLE.id, args.VARIABLE.name);
|
||||
variable.value = args.VALUE;
|
||||
|
||||
if (variable.isCloud) {
|
||||
util.ioQuery('cloud', 'requestUpdateVariable', [variable.name, args.VALUE]);
|
||||
}
|
||||
}
|
||||
|
||||
changeVariableBy (args, util) {
|
||||
const variable = util.target.lookupOrCreateVariable(
|
||||
args.VARIABLE.id, args.VARIABLE.name);
|
||||
const castedValue = Cast.toNumber(variable.value);
|
||||
const dValue = Cast.toNumber(args.VALUE);
|
||||
const newValue = castedValue + dValue;
|
||||
variable.value = newValue;
|
||||
|
||||
if (variable.isCloud) {
|
||||
util.ioQuery('cloud', 'requestUpdateVariable', [variable.name, newValue]);
|
||||
}
|
||||
}
|
||||
|
||||
changeMonitorVisibility (id, visible) {
|
||||
// Send the monitor blocks an event like the flyout checkbox event.
|
||||
// This both updates the monitor state and changes the isMonitored block flag.
|
||||
this.runtime.monitorBlocks.changeBlock({
|
||||
id: id, // Monitor blocks for variables are the variable ID.
|
||||
element: 'checkbox', // Mimic checkbox event from flyout.
|
||||
value: visible
|
||||
}, this.runtime);
|
||||
}
|
||||
|
||||
showVariable (args) {
|
||||
this.changeMonitorVisibility(args.VARIABLE.id, true);
|
||||
}
|
||||
|
||||
hideVariable (args) {
|
||||
this.changeMonitorVisibility(args.VARIABLE.id, false);
|
||||
}
|
||||
|
||||
showList (args) {
|
||||
this.changeMonitorVisibility(args.LIST.id, true);
|
||||
}
|
||||
|
||||
hideList (args) {
|
||||
this.changeMonitorVisibility(args.LIST.id, false);
|
||||
}
|
||||
|
||||
getListContents (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
|
||||
// If block is running for monitors, return copy of list as an array if changed.
|
||||
if (util.thread.updateMonitor) {
|
||||
// Return original list value if up-to-date, which doesn't trigger monitor update.
|
||||
if (list._monitorUpToDate) return list.value;
|
||||
// If value changed, reset the flag and return a copy to trigger monitor update.
|
||||
// Because monitors use Immutable data structures, only new objects trigger updates.
|
||||
list._monitorUpToDate = true;
|
||||
return list.value.slice();
|
||||
}
|
||||
|
||||
// Determine if the list is all single letters.
|
||||
// If it is, report contents joined together with no separator.
|
||||
// If it's not, report contents joined together with a space.
|
||||
let allSingleLetters = true;
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
const listItem = list.value[i];
|
||||
if (!((typeof listItem === 'string') &&
|
||||
(listItem.length === 1))) {
|
||||
allSingleLetters = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allSingleLetters) {
|
||||
return list.value.join('');
|
||||
}
|
||||
return list.value.join(' ');
|
||||
|
||||
}
|
||||
|
||||
addToList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
list.value.push(args.ITEM);
|
||||
list._monitorUpToDate = false;
|
||||
}
|
||||
|
||||
deleteOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length, true);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
} else if (index === Cast.LIST_ALL) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
list.value.splice(index - 1, 1);
|
||||
list._monitorUpToDate = false;
|
||||
}
|
||||
|
||||
deleteAllOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
insertAtList (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length + 1, false);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
}
|
||||
list.value.splice(index - 1, 0, item);
|
||||
list._monitorUpToDate = false;
|
||||
}
|
||||
|
||||
replaceItemOfList (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
}
|
||||
list.value[index - 1] = item;
|
||||
list._monitorUpToDate = false;
|
||||
}
|
||||
|
||||
getItemOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return '';
|
||||
}
|
||||
return list.value[index - 1];
|
||||
}
|
||||
|
||||
getItemNumOfList (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
|
||||
// Go through the list items one-by-one using Cast.compare. This is for
|
||||
// cases like checking if 123 is contained in a list [4, 7, '123'] --
|
||||
// Scratch considers 123 and '123' to be equal.
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
if (Cast.compare(list.value[i], item) === 0) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// We don't bother using .indexOf() at all, because it would end up with
|
||||
// edge cases such as the index of '123' in [4, 7, 123, '123', 9].
|
||||
// If we use indexOf(), this block would return 4 instead of 3, because
|
||||
// indexOf() sees the first occurence of the string 123 as the fourth
|
||||
// item in the list. With Scratch, this would be confusing -- after all,
|
||||
// '123' and 123 look the same, so one would expect the block to say
|
||||
// that the first occurrence of '123' (or 123) to be the third item.
|
||||
|
||||
// Default to 0 if there's no match. Since Scratch lists are 1-indexed,
|
||||
// we don't have to worry about this conflicting with the "this item is
|
||||
// the first value" number (in JS that is 0, but in Scratch it's 1).
|
||||
return 0;
|
||||
}
|
||||
|
||||
lengthOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
return list.value.length;
|
||||
}
|
||||
|
||||
listContainsItem (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
if (list.value.indexOf(item) >= 0) {
|
||||
return true;
|
||||
}
|
||||
// Try using Scratch comparison operator on each item.
|
||||
// (Scratch considers the string '123' equal to the number 123).
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
if (Cast.compare(list.value[i], item) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3DataBlocks;
|
||||
137
scratch-vm/src/blocks/scratch3_event.js
Normal file
137
scratch-vm/src/blocks/scratch3_event.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const Cast = require('../util/cast');
|
||||
|
||||
class Scratch3EventBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
this.runtime.on('KEY_PRESSED', key => {
|
||||
this.runtime.startHats('event_whenkeypressed', {
|
||||
KEY_OPTION: key
|
||||
});
|
||||
this.runtime.startHats('event_whenkeypressed', {
|
||||
KEY_OPTION: 'any'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
event_whentouchingobject: this.touchingObject,
|
||||
event_broadcast: this.broadcast,
|
||||
event_broadcastandwait: this.broadcastAndWait,
|
||||
event_whengreaterthan: this.hatGreaterThanPredicate
|
||||
};
|
||||
}
|
||||
|
||||
getHats () {
|
||||
return {
|
||||
event_whenflagclicked: {
|
||||
restartExistingThreads: true
|
||||
},
|
||||
event_whenkeypressed: {
|
||||
restartExistingThreads: false
|
||||
},
|
||||
event_whenthisspriteclicked: {
|
||||
restartExistingThreads: true
|
||||
},
|
||||
event_whentouchingobject: {
|
||||
restartExistingThreads: false,
|
||||
edgeActivated: true
|
||||
},
|
||||
event_whenstageclicked: {
|
||||
restartExistingThreads: true
|
||||
},
|
||||
event_whenbackdropswitchesto: {
|
||||
restartExistingThreads: true
|
||||
},
|
||||
event_whengreaterthan: {
|
||||
restartExistingThreads: false,
|
||||
edgeActivated: true
|
||||
},
|
||||
event_whenbroadcastreceived: {
|
||||
restartExistingThreads: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
touchingObject (args, util) {
|
||||
return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU);
|
||||
}
|
||||
|
||||
hatGreaterThanPredicate (args, util) {
|
||||
const option = Cast.toString(args.WHENGREATERTHANMENU).toLowerCase();
|
||||
const value = Cast.toNumber(args.VALUE);
|
||||
switch (option) {
|
||||
case 'timer':
|
||||
return util.ioQuery('clock', 'projectTimer') > value;
|
||||
case 'loudness':
|
||||
return this.runtime.audioEngine && this.runtime.audioEngine.getLoudness() > value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
broadcast (args, util) {
|
||||
const broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
|
||||
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
|
||||
if (broadcastVar) {
|
||||
const broadcastOption = broadcastVar.name;
|
||||
util.startHats('event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
broadcastAndWait (args, util) {
|
||||
if (!util.stackFrame.broadcastVar) {
|
||||
util.stackFrame.broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
|
||||
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
|
||||
}
|
||||
if (util.stackFrame.broadcastVar) {
|
||||
const broadcastOption = util.stackFrame.broadcastVar.name;
|
||||
// Have we run before, starting threads?
|
||||
if (!util.stackFrame.startedThreads) {
|
||||
// No - start hats for this broadcast.
|
||||
util.stackFrame.startedThreads = util.startHats(
|
||||
'event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
}
|
||||
);
|
||||
if (util.stackFrame.startedThreads.length === 0) {
|
||||
// Nothing was started.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// We've run before; check if the wait is still going on.
|
||||
const instance = this;
|
||||
// Scratch 2 considers threads to be waiting if they are still in
|
||||
// runtime.threads. Threads that have run all their blocks, or are
|
||||
// marked done but still in runtime.threads are still considered to
|
||||
// be waiting.
|
||||
const waiting = util.stackFrame.startedThreads
|
||||
.some(thread => instance.runtime.threads.indexOf(thread) !== -1);
|
||||
if (waiting) {
|
||||
// If all threads are waiting for the next tick or later yield
|
||||
// for a tick as well. Otherwise yield until the next loop of
|
||||
// the threads.
|
||||
if (
|
||||
util.stackFrame.startedThreads
|
||||
.every(thread => instance.runtime.isWaitingThread(thread))
|
||||
) {
|
||||
util.yieldTick();
|
||||
} else {
|
||||
util.yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3EventBlocks;
|
||||
615
scratch-vm/src/blocks/scratch3_looks.js
Normal file
615
scratch-vm/src/blocks/scratch3_looks.js
Normal file
@@ -0,0 +1,615 @@
|
||||
const Cast = require('../util/cast');
|
||||
const Clone = require('../util/clone');
|
||||
const uid = require('../util/uid');
|
||||
const StageLayering = require('../engine/stage-layering');
|
||||
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');
|
||||
const MathUtil = require('../util/math-util');
|
||||
|
||||
/**
|
||||
* @typedef {object} BubbleState - the bubble state associated with a particular target.
|
||||
* @property {Boolean} onSpriteRight - tracks whether the bubble is right or left of the sprite.
|
||||
* @property {?int} drawableId - the ID of the associated bubble Drawable, null if none.
|
||||
* @property {string} text - the text of the bubble.
|
||||
* @property {string} type - the type of the bubble, "say" or "think"
|
||||
* @property {?string} usageId - ID indicating the most recent usage of the say/think bubble.
|
||||
* Used for comparison when determining whether to clear a say/think bubble.
|
||||
*/
|
||||
|
||||
class Scratch3LooksBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
this._onTargetChanged = this._onTargetChanged.bind(this);
|
||||
this._onResetBubbles = this._onResetBubbles.bind(this);
|
||||
this._onTargetWillExit = this._onTargetWillExit.bind(this);
|
||||
this._updateBubble = this._updateBubble.bind(this);
|
||||
|
||||
// Reset all bubbles on start/stop
|
||||
this.runtime.on('PROJECT_STOP_ALL', this._onResetBubbles);
|
||||
this.runtime.on('targetWasRemoved', this._onTargetWillExit);
|
||||
|
||||
// Enable other blocks to use bubbles like ask/answer
|
||||
this.runtime.on(Scratch3LooksBlocks.SAY_OR_THINK, this._updateBubble);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default bubble state, to be used when a target has no existing bubble state.
|
||||
* @type {BubbleState}
|
||||
*/
|
||||
static get DEFAULT_BUBBLE_STATE () {
|
||||
return {
|
||||
drawableId: null,
|
||||
onSpriteRight: true,
|
||||
skinId: null,
|
||||
text: '',
|
||||
type: 'say',
|
||||
usageId: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The key to load & store a target's bubble-related state.
|
||||
* @type {string}
|
||||
*/
|
||||
static get STATE_KEY () {
|
||||
return 'Scratch.looks';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event name for a text bubble being created or updated.
|
||||
* @const {string}
|
||||
*/
|
||||
static get SAY_OR_THINK () {
|
||||
// There are currently many places in the codebase which explicitly refer to this event by the string 'SAY',
|
||||
// so keep this as the string 'SAY' for now rather than changing it to 'SAY_OR_THINK' and breaking things.
|
||||
return 'SAY';
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit for say bubble string.
|
||||
* @const {string}
|
||||
*/
|
||||
static get SAY_BUBBLE_LIMIT () {
|
||||
return 330;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit for ghost effect
|
||||
* @const {object}
|
||||
*/
|
||||
static get EFFECT_GHOST_LIMIT (){
|
||||
return {min: 0, max: 100};
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit for brightness effect
|
||||
* @const {object}
|
||||
*/
|
||||
static get EFFECT_BRIGHTNESS_LIMIT (){
|
||||
return {min: -100, max: 100};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Target} target - collect bubble state for this target. Probably, but not necessarily, a RenderedTarget.
|
||||
* @returns {BubbleState} the mutable bubble state associated with that target. This will be created if necessary.
|
||||
* @private
|
||||
*/
|
||||
_getBubbleState (target) {
|
||||
let bubbleState = target.getCustomState(Scratch3LooksBlocks.STATE_KEY);
|
||||
if (!bubbleState) {
|
||||
bubbleState = Clone.simple(Scratch3LooksBlocks.DEFAULT_BUBBLE_STATE);
|
||||
target.setCustomState(Scratch3LooksBlocks.STATE_KEY, bubbleState);
|
||||
}
|
||||
return bubbleState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a target which has moved.
|
||||
* @param {RenderedTarget} target - the target which has moved.
|
||||
* @private
|
||||
*/
|
||||
_onTargetChanged (target) {
|
||||
const bubbleState = this._getBubbleState(target);
|
||||
if (bubbleState.drawableId) {
|
||||
this._positionBubble(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a target which is exiting.
|
||||
* @param {RenderedTarget} target - the target.
|
||||
* @private
|
||||
*/
|
||||
_onTargetWillExit (target) {
|
||||
const bubbleState = this._getBubbleState(target);
|
||||
if (bubbleState.drawableId && bubbleState.skinId) {
|
||||
this.runtime.renderer.destroyDrawable(bubbleState.drawableId, StageLayering.SPRITE_LAYER);
|
||||
this.runtime.renderer.destroySkin(bubbleState.skinId);
|
||||
bubbleState.drawableId = null;
|
||||
bubbleState.skinId = null;
|
||||
this.runtime.requestRedraw();
|
||||
}
|
||||
target.onTargetVisualChange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project start/stop by clearing all visible bubbles.
|
||||
* @private
|
||||
*/
|
||||
_onResetBubbles () {
|
||||
for (let n = 0; n < this.runtime.targets.length; n++) {
|
||||
const bubbleState = this._getBubbleState(this.runtime.targets[n]);
|
||||
bubbleState.text = '';
|
||||
this._onTargetWillExit(this.runtime.targets[n]);
|
||||
}
|
||||
clearTimeout(this._bubbleTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the bubble of a target. If it doesn't fit on the specified side, flip and rerender.
|
||||
* @param {!Target} target Target whose bubble needs positioning.
|
||||
* @private
|
||||
*/
|
||||
_positionBubble (target) {
|
||||
if (!target.visible) return;
|
||||
const bubbleState = this._getBubbleState(target);
|
||||
const [bubbleWidth, bubbleHeight] = this.runtime.renderer.getCurrentSkinSize(bubbleState.drawableId);
|
||||
let targetBounds;
|
||||
try {
|
||||
targetBounds = target.getBoundsForBubble();
|
||||
} catch (error_) {
|
||||
// Bounds calculation could fail (e.g. on empty costumes), in that case
|
||||
// use the x/y position of the target.
|
||||
targetBounds = {
|
||||
left: target.x,
|
||||
right: target.x,
|
||||
top: target.y,
|
||||
bottom: target.y
|
||||
};
|
||||
}
|
||||
const stageSize = this.runtime.renderer.getNativeSize();
|
||||
const stageBounds = {
|
||||
left: -stageSize[0] / 2,
|
||||
right: stageSize[0] / 2,
|
||||
top: stageSize[1] / 2,
|
||||
bottom: -stageSize[1] / 2
|
||||
};
|
||||
if (bubbleState.onSpriteRight && bubbleWidth + targetBounds.right > stageBounds.right &&
|
||||
(targetBounds.left - bubbleWidth > stageBounds.left)) { // Only flip if it would fit
|
||||
bubbleState.onSpriteRight = false;
|
||||
this._renderBubble(target);
|
||||
} else if (!bubbleState.onSpriteRight && targetBounds.left - bubbleWidth < stageBounds.left &&
|
||||
(bubbleWidth + targetBounds.right < stageBounds.right)) { // Only flip if it would fit
|
||||
bubbleState.onSpriteRight = true;
|
||||
this._renderBubble(target);
|
||||
} else {
|
||||
this.runtime.renderer.updateDrawablePosition(bubbleState.drawableId, [
|
||||
bubbleState.onSpriteRight ? (
|
||||
Math.max(
|
||||
stageBounds.left, // Bubble should not extend past left edge of stage
|
||||
Math.min(stageBounds.right - bubbleWidth, targetBounds.right)
|
||||
)
|
||||
) : (
|
||||
Math.min(
|
||||
stageBounds.right - bubbleWidth, // Bubble should not extend past right edge of stage
|
||||
Math.max(stageBounds.left, targetBounds.left - bubbleWidth)
|
||||
)
|
||||
),
|
||||
// Bubble should not extend past the top of the stage
|
||||
Math.min(stageBounds.top, targetBounds.bottom + bubbleHeight)
|
||||
]);
|
||||
this.runtime.requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a visible bubble for a target. If a bubble exists for the target,
|
||||
* just set it to visible and update the type/text. Otherwise create a new
|
||||
* bubble and update the relevant custom state.
|
||||
* @param {!Target} target Target who needs a bubble.
|
||||
* @return {undefined} Early return if text is empty string.
|
||||
* @private
|
||||
*/
|
||||
_renderBubble (target) { // used by compiler
|
||||
if (!this.runtime.renderer) return;
|
||||
|
||||
const bubbleState = this._getBubbleState(target);
|
||||
const {type, text, onSpriteRight} = bubbleState;
|
||||
|
||||
// Remove the bubble if target is not visible, or text is being set to blank.
|
||||
if (!target.visible || text === '') {
|
||||
this._onTargetWillExit(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bubbleState.skinId) {
|
||||
this.runtime.renderer.updateTextSkin(bubbleState.skinId, type, text, onSpriteRight, [0, 0]);
|
||||
} else {
|
||||
target.onTargetVisualChange = this._onTargetChanged;
|
||||
bubbleState.drawableId = this.runtime.renderer.createDrawable(StageLayering.SPRITE_LAYER);
|
||||
bubbleState.skinId = this.runtime.renderer.createTextSkin(type, text, bubbleState.onSpriteRight, [0, 0]);
|
||||
this.runtime.renderer.updateDrawableSkinId(bubbleState.drawableId, bubbleState.skinId);
|
||||
}
|
||||
|
||||
this._positionBubble(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly format text for a text bubble.
|
||||
* @param {string} text The text to be formatted
|
||||
* @return {string} The formatted text
|
||||
* @private
|
||||
*/
|
||||
_formatBubbleText (text) {
|
||||
if (text === '') return text;
|
||||
|
||||
// Non-integers should be rounded to 2 decimal places (no more, no less), unless they're small enough that
|
||||
// rounding would display them as 0.00. This matches 2.0's behavior:
|
||||
// https://github.com/scratchfoundation/scratch-flash/blob/2e4a402ceb205a042887f54b26eebe1c2e6da6c0/src/scratch/ScratchSprite.as#L579-L585
|
||||
if (typeof text === 'number' &&
|
||||
Math.abs(text) >= 0.01 && text % 1 !== 0) {
|
||||
text = text.toFixed(2);
|
||||
}
|
||||
|
||||
// Limit the length of the string.
|
||||
text = String(text).substr(0, Scratch3LooksBlocks.SAY_BUBBLE_LIMIT);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry point for say/think blocks. Clears existing bubble if the text is empty.
|
||||
* Set the bubble custom state and then call _renderBubble.
|
||||
* @param {!Target} target Target that say/think blocks are being called on.
|
||||
* @param {!string} type Either "say" or "think"
|
||||
* @param {!string} text The text for the bubble, empty string clears the bubble.
|
||||
* @private
|
||||
*/
|
||||
_updateBubble (target, type, text) {
|
||||
const bubbleState = this._getBubbleState(target);
|
||||
bubbleState.type = type;
|
||||
bubbleState.text = this._formatBubbleText(text);
|
||||
bubbleState.usageId = uid();
|
||||
this._renderBubble(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
looks_say: this.say,
|
||||
looks_sayforsecs: this.sayforsecs,
|
||||
looks_think: this.think,
|
||||
looks_thinkforsecs: this.thinkforsecs,
|
||||
looks_show: this.show,
|
||||
looks_hide: this.hide,
|
||||
looks_hideallsprites: () => {}, // legacy no-op block
|
||||
looks_switchcostumeto: this.switchCostume,
|
||||
looks_switchbackdropto: this.switchBackdrop,
|
||||
looks_switchbackdroptoandwait: this.switchBackdropAndWait,
|
||||
looks_nextcostume: this.nextCostume,
|
||||
looks_nextbackdrop: this.nextBackdrop,
|
||||
looks_changeeffectby: this.changeEffect,
|
||||
looks_seteffectto: this.setEffect,
|
||||
looks_cleargraphiceffects: this.clearEffects,
|
||||
looks_changesizeby: this.changeSize,
|
||||
looks_setsizeto: this.setSize,
|
||||
looks_changestretchby: () => {}, // legacy no-op blocks
|
||||
looks_setstretchto: () => {},
|
||||
looks_gotofrontback: this.goToFrontBack,
|
||||
looks_goforwardbackwardlayers: this.goForwardBackwardLayers,
|
||||
looks_size: this.getSize,
|
||||
looks_costumenumbername: this.getCostumeNumberName,
|
||||
looks_backdropnumbername: this.getBackdropNumberName
|
||||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
looks_size: {
|
||||
isSpriteSpecific: true,
|
||||
getId: targetId => `${targetId}_size`
|
||||
},
|
||||
looks_costumenumbername: {
|
||||
isSpriteSpecific: true,
|
||||
getId: (targetId, fields) => getMonitorIdForBlockWithArgs(`${targetId}_costumenumbername`, fields)
|
||||
},
|
||||
looks_backdropnumbername: {
|
||||
getId: (_, fields) => getMonitorIdForBlockWithArgs('backdropnumbername', fields)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
say (args, util) {
|
||||
// @TODO in 2.0 calling say/think resets the right/left bias of the bubble
|
||||
const message = args.MESSAGE;
|
||||
this._say(message, util.target);
|
||||
}
|
||||
_say (message, target) { // used by compiler
|
||||
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, target, 'say', message);
|
||||
}
|
||||
|
||||
sayforsecs (args, util) {
|
||||
this.say(args, util);
|
||||
const target = util.target;
|
||||
const usageId = this._getBubbleState(target).usageId;
|
||||
return new Promise(resolve => {
|
||||
this._bubbleTimeout = setTimeout(() => {
|
||||
this._bubbleTimeout = null;
|
||||
// Clear say bubble if it hasn't been changed and proceed.
|
||||
if (this._getBubbleState(target).usageId === usageId) {
|
||||
this._updateBubble(target, 'say', '');
|
||||
}
|
||||
resolve();
|
||||
}, 1000 * args.SECS);
|
||||
});
|
||||
}
|
||||
|
||||
think (args, util) {
|
||||
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, util.target, 'think', args.MESSAGE);
|
||||
}
|
||||
|
||||
thinkforsecs (args, util) {
|
||||
this.think(args, util);
|
||||
const target = util.target;
|
||||
const usageId = this._getBubbleState(target).usageId;
|
||||
return new Promise(resolve => {
|
||||
this._bubbleTimeout = setTimeout(() => {
|
||||
this._bubbleTimeout = null;
|
||||
// Clear think bubble if it hasn't been changed and proceed.
|
||||
if (this._getBubbleState(target).usageId === usageId) {
|
||||
this._updateBubble(target, 'think', '');
|
||||
}
|
||||
resolve();
|
||||
}, 1000 * args.SECS);
|
||||
});
|
||||
}
|
||||
|
||||
show (args, util) {
|
||||
util.target.setVisible(true);
|
||||
this._renderBubble(util.target);
|
||||
}
|
||||
|
||||
hide (args, util) {
|
||||
util.target.setVisible(false);
|
||||
this._renderBubble(util.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to set the costume of a target.
|
||||
* Matches the behavior of Scratch 2.0 for different types of arguments.
|
||||
* @param {!Target} target Target to set costume to.
|
||||
* @param {Any} requestedCostume Costume requested, e.g., 0, 'name', etc.
|
||||
* @param {boolean=} optZeroIndex Set to zero-index the requestedCostume.
|
||||
* @return {Array.<!Thread>} Any threads started by this switch.
|
||||
*/
|
||||
_setCostume (target, requestedCostume, optZeroIndex) { // used by compiler
|
||||
if (typeof requestedCostume === 'number') {
|
||||
// Numbers should be treated as costume indices, always
|
||||
target.setCostume(optZeroIndex ? requestedCostume : requestedCostume - 1);
|
||||
} else {
|
||||
// Strings should be treated as costume names, where possible
|
||||
const costumeIndex = target.getCostumeIndexByName(requestedCostume.toString());
|
||||
|
||||
if (costumeIndex !== -1) {
|
||||
target.setCostume(costumeIndex);
|
||||
} else if (requestedCostume === 'next costume') {
|
||||
target.setCostume(target.currentCostume + 1);
|
||||
} else if (requestedCostume === 'previous costume') {
|
||||
target.setCostume(target.currentCostume - 1);
|
||||
// Try to cast the string to a number (and treat it as a costume index)
|
||||
// Pure whitespace should not be treated as a number
|
||||
// Note: isNaN will cast the string to a number before checking if it's NaN
|
||||
} else if (!(isNaN(requestedCostume) || Cast.isWhiteSpace(requestedCostume))) {
|
||||
target.setCostume(optZeroIndex ? Number(requestedCostume) : Number(requestedCostume) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Per 2.0, 'switch costume' can't start threads even in the Stage.
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to set the backdrop of a target.
|
||||
* Matches the behavior of Scratch 2.0 for different types of arguments.
|
||||
* @param {!Target} stage Target to set backdrop to.
|
||||
* @param {Any} requestedBackdrop Backdrop requested, e.g., 0, 'name', etc.
|
||||
* @param {boolean=} optZeroIndex Set to zero-index the requestedBackdrop.
|
||||
* @return {Array.<!Thread>} Any threads started by this switch.
|
||||
*/
|
||||
_setBackdrop (stage, requestedBackdrop, optZeroIndex) { // used by compiler
|
||||
if (typeof requestedBackdrop === 'number') {
|
||||
// Numbers should be treated as backdrop indices, always
|
||||
stage.setCostume(optZeroIndex ? requestedBackdrop : requestedBackdrop - 1);
|
||||
} else {
|
||||
// Strings should be treated as backdrop names where possible
|
||||
const costumeIndex = stage.getCostumeIndexByName(requestedBackdrop.toString());
|
||||
|
||||
if (costumeIndex !== -1) {
|
||||
stage.setCostume(costumeIndex);
|
||||
} else if (requestedBackdrop === 'next backdrop') {
|
||||
stage.setCostume(stage.currentCostume + 1);
|
||||
} else if (requestedBackdrop === 'previous backdrop') {
|
||||
stage.setCostume(stage.currentCostume - 1);
|
||||
} else if (requestedBackdrop === 'random backdrop') {
|
||||
const numCostumes = stage.getCostumes().length;
|
||||
if (numCostumes > 1) {
|
||||
// Don't pick the current backdrop, so that the block
|
||||
// will always have an observable effect.
|
||||
const lowerBound = 0;
|
||||
const upperBound = numCostumes - 1;
|
||||
const costumeToExclude = stage.currentCostume;
|
||||
|
||||
const nextCostume = MathUtil.inclusiveRandIntWithout(lowerBound, upperBound, costumeToExclude);
|
||||
|
||||
stage.setCostume(nextCostume);
|
||||
}
|
||||
// Try to cast the string to a number (and treat it as a costume index)
|
||||
// Pure whitespace should not be treated as a number
|
||||
// Note: isNaN will cast the string to a number before checking if it's NaN
|
||||
} else if (!(isNaN(requestedBackdrop) || Cast.isWhiteSpace(requestedBackdrop))) {
|
||||
stage.setCostume(optZeroIndex ? Number(requestedBackdrop) : Number(requestedBackdrop) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const newName = stage.getCostumes()[stage.currentCostume].name;
|
||||
return this.runtime.startHats('event_whenbackdropswitchesto', {
|
||||
BACKDROP: newName
|
||||
});
|
||||
}
|
||||
|
||||
switchCostume (args, util) {
|
||||
this._setCostume(util.target, args.COSTUME); // used by compiler
|
||||
}
|
||||
|
||||
nextCostume (args, util) {
|
||||
this._setCostume(
|
||||
util.target, util.target.currentCostume + 1, true
|
||||
);
|
||||
}
|
||||
|
||||
switchBackdrop (args) {
|
||||
this._setBackdrop(this.runtime.getTargetForStage(), args.BACKDROP);
|
||||
}
|
||||
|
||||
switchBackdropAndWait (args, util) {
|
||||
// Have we run before, starting threads?
|
||||
if (!util.stackFrame.startedThreads) {
|
||||
// No - switch the backdrop.
|
||||
util.stackFrame.startedThreads = (
|
||||
this._setBackdrop(
|
||||
this.runtime.getTargetForStage(),
|
||||
args.BACKDROP
|
||||
)
|
||||
);
|
||||
if (util.stackFrame.startedThreads.length === 0) {
|
||||
// Nothing was started.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// We've run before; check if the wait is still going on.
|
||||
const instance = this;
|
||||
// Scratch 2 considers threads to be waiting if they are still in
|
||||
// runtime.threads. Threads that have run all their blocks, or are
|
||||
// marked done but still in runtime.threads are still considered to
|
||||
// be waiting.
|
||||
const waiting = util.stackFrame.startedThreads
|
||||
.some(thread => instance.runtime.threads.indexOf(thread) !== -1);
|
||||
if (waiting) {
|
||||
// If all threads are waiting for the next tick or later yield
|
||||
// for a tick as well. Otherwise yield until the next loop of
|
||||
// the threads.
|
||||
if (
|
||||
util.stackFrame.startedThreads
|
||||
.every(thread => instance.runtime.isWaitingThread(thread))
|
||||
) {
|
||||
util.yieldTick();
|
||||
} else {
|
||||
util.yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextBackdrop () {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
this._setBackdrop(
|
||||
stage, stage.currentCostume + 1, true
|
||||
);
|
||||
}
|
||||
|
||||
clampEffect (effect, value) { // used by compiler
|
||||
let clampedValue = value;
|
||||
switch (effect) {
|
||||
case 'ghost':
|
||||
clampedValue = MathUtil.clamp(value,
|
||||
Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.min,
|
||||
Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.max);
|
||||
break;
|
||||
case 'brightness':
|
||||
clampedValue = MathUtil.clamp(value,
|
||||
Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.min,
|
||||
Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.max);
|
||||
break;
|
||||
}
|
||||
return clampedValue;
|
||||
}
|
||||
|
||||
changeEffect (args, util) {
|
||||
const effect = Cast.toString(args.EFFECT).toLowerCase();
|
||||
const change = Cast.toNumber(args.CHANGE);
|
||||
if (!Object.prototype.hasOwnProperty.call(util.target.effects, effect)) return;
|
||||
let newValue = change + util.target.effects[effect];
|
||||
newValue = this.clampEffect(effect, newValue);
|
||||
util.target.setEffect(effect, newValue);
|
||||
}
|
||||
|
||||
setEffect (args, util) {
|
||||
const effect = Cast.toString(args.EFFECT).toLowerCase();
|
||||
let value = Cast.toNumber(args.VALUE);
|
||||
value = this.clampEffect(effect, value);
|
||||
util.target.setEffect(effect, value);
|
||||
}
|
||||
|
||||
clearEffects (args, util) {
|
||||
util.target.clearEffects();
|
||||
}
|
||||
|
||||
changeSize (args, util) {
|
||||
const change = Cast.toNumber(args.CHANGE);
|
||||
util.target.setSize(util.target.size + change);
|
||||
}
|
||||
|
||||
setSize (args, util) {
|
||||
const size = Cast.toNumber(args.SIZE);
|
||||
util.target.setSize(size);
|
||||
}
|
||||
|
||||
goToFrontBack (args, util) {
|
||||
if (!util.target.isStage) {
|
||||
if (args.FRONT_BACK === 'front') {
|
||||
util.target.goToFront();
|
||||
} else {
|
||||
util.target.goToBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goForwardBackwardLayers (args, util) {
|
||||
if (!util.target.isStage) {
|
||||
if (args.FORWARD_BACKWARD === 'forward') {
|
||||
util.target.goForwardLayers(Cast.toNumber(args.NUM));
|
||||
} else {
|
||||
util.target.goBackwardLayers(Cast.toNumber(args.NUM));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSize (args, util) {
|
||||
return Math.round(util.target.size);
|
||||
}
|
||||
|
||||
getBackdropNumberName (args) {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
if (args.NUMBER_NAME === 'number') {
|
||||
return stage.currentCostume + 1;
|
||||
}
|
||||
// Else return name
|
||||
return stage.getCostumes()[stage.currentCostume].name;
|
||||
}
|
||||
|
||||
getCostumeNumberName (args, util) {
|
||||
if (args.NUMBER_NAME === 'number') {
|
||||
return util.target.currentCostume + 1;
|
||||
}
|
||||
// Else return name
|
||||
return util.target.getCostumes()[util.target.currentCostume].name;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3LooksBlocks;
|
||||
328
scratch-vm/src/blocks/scratch3_motion.js
Normal file
328
scratch-vm/src/blocks/scratch3_motion.js
Normal file
@@ -0,0 +1,328 @@
|
||||
const Cast = require('../util/cast');
|
||||
const { debug } = require('../util/log');
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
console.log("Primitives being registered");
|
||||
return {
|
||||
motion_movesteps: this.moveSteps,
|
||||
motion_testblock: this.testblock,
|
||||
motion_move_f: this.MoveFSteps,
|
||||
motion_move_b: this.MoveBSteps,
|
||||
motion_cmd_print: this.CMDprint,
|
||||
motion_gotoxy: this.goToXY,
|
||||
motion_goto: this.goTo,
|
||||
motion_turnright: this.turnRight,
|
||||
motion_turnleft: this.turnLeft,
|
||||
motion_jump_move: this.motion_jump_move,
|
||||
motion_pointindirection: this.pointInDirection,
|
||||
motion_pointtowards: this.pointTowards,
|
||||
motion_glidesecstoxy: this.glide,
|
||||
motion_glideto: this.glideTo,
|
||||
motion_ifonedgebounce: this.ifOnEdgeBounce,
|
||||
motion_setrotationstyle: this.setRotationStyle,
|
||||
motion_changexby: this.changeX,
|
||||
motion_setx: this.setX,
|
||||
motion_changeyby: this.changeY,
|
||||
motion_sety: this.setY,
|
||||
motion_xposition: this.getX,
|
||||
motion_yposition: this.getY,
|
||||
motion_direction: this.getDirection,
|
||||
// Legacy no-op blocks:
|
||||
motion_scroll_right: () => {},
|
||||
motion_scroll_up: () => {},
|
||||
motion_align_scene: () => {},
|
||||
motion_xscroll: () => {},
|
||||
motion_yscroll: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
motion_xposition: {
|
||||
isSpriteSpecific: true,
|
||||
getId: targetId => `${targetId}_xposition`
|
||||
},
|
||||
motion_yposition: {
|
||||
isSpriteSpecific: true,
|
||||
getId: targetId => `${targetId}_yposition`
|
||||
},
|
||||
motion_direction: {
|
||||
isSpriteSpecific: true,
|
||||
getId: targetId => `${targetId}_direction`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
moveSteps (args, util) {
|
||||
const steps = Cast.toNumber(args.STEPS);
|
||||
this._moveSteps(steps, util.target);
|
||||
}
|
||||
|
||||
MoveBSteps (args, util) {
|
||||
// const steps = Cast.toNumber(args.STEPS);
|
||||
// this._moveSteps(steps, util.target);
|
||||
}
|
||||
|
||||
MoveFSteps (args, util) {
|
||||
// const steps = Cast.toNumber(args.STEPS);
|
||||
// this._moveSteps(steps, util.target);
|
||||
}
|
||||
|
||||
CMDprint (args, util) {
|
||||
// const text = Cast.toString(args.TEXT);
|
||||
// console.log(text);
|
||||
}
|
||||
|
||||
_moveSteps (steps, target) { // used by compiler
|
||||
const radians = MathUtil.degToRad(90 - target.direction);
|
||||
const dx = steps * Math.cos(radians);
|
||||
const dy = steps * Math.sin(radians);
|
||||
target.setXY(target.x + dx, 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.stageWidth;
|
||||
const stageHeight = this.runtime.stageHeight;
|
||||
targetX = Math.round(stageWidth * (Math.random() - 0.5));
|
||||
targetY = Math.round(stageHeight * (Math.random() - 0.5));
|
||||
} else {
|
||||
targetName = Cast.toString(targetName);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
testblock (args, util) {
|
||||
const steps = Cast.toNumber(args.STEPS);
|
||||
this._moveSteps(steps, util.target);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
motion_jump_move (args, util) {
|
||||
|
||||
}
|
||||
|
||||
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 if (args.TOWARDS === '_random_') {
|
||||
util.target.setDirection(Math.round(Math.random() * 360) - 180);
|
||||
return;
|
||||
} else {
|
||||
args.TOWARDS = Cast.toString(args.TOWARDS);
|
||||
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) {
|
||||
this._ifOnEdgeBounce(util.target);
|
||||
}
|
||||
_ifOnEdgeBounce (target) { // used by compiler
|
||||
const bounds = target.getBounds();
|
||||
if (!bounds) {
|
||||
return;
|
||||
}
|
||||
// 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.stageWidth;
|
||||
const stageHeight = this.runtime.stageHeight;
|
||||
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 - 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;
|
||||
target.setDirection(newDirection);
|
||||
// Keep within the stage.
|
||||
const fencedPosition = target.keepInFence(target.x, target.y);
|
||||
target.setXY(fencedPosition[0], fencedPosition[1]);
|
||||
}
|
||||
|
||||
setRotationStyle (args, util) {
|
||||
util.target.setRotationStyle(args.STYLE);
|
||||
}
|
||||
|
||||
changeX (args, util) {
|
||||
const dx = Cast.toNumber(args.DX);
|
||||
util.target.setXY(util.target.x + dx, util.target.y);
|
||||
}
|
||||
|
||||
setX (args, util) {
|
||||
const x = Cast.toNumber(args.X);
|
||||
util.target.setXY(x, util.target.y);
|
||||
}
|
||||
|
||||
changeY (args, util) {
|
||||
const dy = Cast.toNumber(args.DY);
|
||||
util.target.setXY(util.target.x, util.target.y + dy);
|
||||
}
|
||||
|
||||
setY (args, util) {
|
||||
debugger
|
||||
const y = Cast.toNumber(args.Y);
|
||||
util.target.setXY(util.target.x, y);
|
||||
}
|
||||
|
||||
getX (args, util) {
|
||||
// return this.limitPrecision(util.target.x);
|
||||
}
|
||||
|
||||
getY (args, util) {
|
||||
// return this.limitPrecision(util.target.y);
|
||||
}
|
||||
|
||||
getDirection (args, util) {
|
||||
// return util.target.direction;
|
||||
}
|
||||
|
||||
// This corresponds to snapToInteger in Scratch 2
|
||||
limitPrecision (coordinate) {
|
||||
const rounded = Math.round(coordinate);
|
||||
const delta = coordinate - rounded;
|
||||
const limitedCoord = (Math.abs(delta) < 1e-9) ? rounded : coordinate;
|
||||
|
||||
return limitedCoord;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3MotionBlocks;
|
||||
157
scratch-vm/src/blocks/scratch3_operators.js
Normal file
157
scratch-vm/src/blocks/scratch3_operators.js
Normal file
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
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) {
|
||||
return this._random(args.FROM, args.TO);
|
||||
}
|
||||
_random (from, to) { // used by compiler
|
||||
const nFrom = Cast.toNumber(from);
|
||||
const nTo = Cast.toNumber(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(from) && Cast.isInt(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 uses floored division instead of truncated division.
|
||||
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 Math.round(Math.sin((Math.PI * n) / 180) * 1e10) / 1e10;
|
||||
case 'cos': return Math.round(Math.cos((Math.PI * n) / 180) * 1e10) / 1e10;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3OperatorsBlocks;
|
||||
136
scratch-vm/src/blocks/scratch3_procedures.js
Normal file
136
scratch-vm/src/blocks/scratch3_procedures.js
Normal file
@@ -0,0 +1,136 @@
|
||||
class Scratch3ProcedureBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
procedures_definition: this.definition,
|
||||
procedures_call: this.call,
|
||||
procedures_return: this.return,
|
||||
argument_reporter_string_number: this.argumentReporterStringNumber,
|
||||
argument_reporter_boolean: this.argumentReporterBoolean
|
||||
};
|
||||
}
|
||||
|
||||
definition () {
|
||||
// No-op: execute the blocks.
|
||||
}
|
||||
|
||||
call (args, util) {
|
||||
const stackFrame = util.stackFrame;
|
||||
const isReporter = !!args.mutation.return;
|
||||
|
||||
if (stackFrame.executed) {
|
||||
if (isReporter) {
|
||||
const returnValue = stackFrame.returnValue;
|
||||
// This stackframe will be reused for other reporters in this block, so clean it up for them.
|
||||
// Can't use reset() because that will reset too much.
|
||||
const threadStackFrame = util.thread.peekStackFrame();
|
||||
threadStackFrame.params = null;
|
||||
delete stackFrame.returnValue;
|
||||
delete stackFrame.executed;
|
||||
return returnValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const procedureCode = args.mutation.proccode;
|
||||
const paramNamesIdsAndDefaults = util.getProcedureParamNamesIdsAndDefaults(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 (paramNamesIdsAndDefaults === null) {
|
||||
if (isReporter) {
|
||||
return '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [paramNames, paramIds, paramDefaults] = paramNamesIdsAndDefaults;
|
||||
|
||||
// Initialize params for the current stackFrame to {}, even if the procedure does
|
||||
// not take any arguments. This is so that `getParam` down the line does not look
|
||||
// at earlier stack frames for the values of a given parameter (#1729)
|
||||
util.initParams();
|
||||
for (let i = 0; i < paramIds.length; i++) {
|
||||
if (Object.prototype.hasOwnProperty.call(args, paramIds[i])) {
|
||||
util.pushParam(paramNames[i], args[paramIds[i]]);
|
||||
} else {
|
||||
util.pushParam(paramNames[i], paramDefaults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const addonBlock = util.runtime.getAddonBlock(procedureCode);
|
||||
if (addonBlock) {
|
||||
const result = addonBlock.callback(util.thread.getAllparams(), util);
|
||||
if (util.thread.status === 1 /* STATUS_PROMISE_WAIT */) {
|
||||
// If the addon block is using STATUS_PROMISE_WAIT to force us to sleep,
|
||||
// make sure to not re-run this block when we resume.
|
||||
stackFrame.executed = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
stackFrame.executed = true;
|
||||
|
||||
if (isReporter) {
|
||||
util.thread.peekStackFrame().waitingReporter = true;
|
||||
// Default return value
|
||||
stackFrame.returnValue = '';
|
||||
}
|
||||
|
||||
util.startProcedure(procedureCode);
|
||||
}
|
||||
|
||||
return (args, util) {
|
||||
util.stopThisScript();
|
||||
// If used outside of a custom block, there may be no stackframe.
|
||||
if (util.thread.peekStackFrame()) {
|
||||
util.stackFrame.returnValue = args.VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
argumentReporterStringNumber (args, util) {
|
||||
const value = util.getParam(args.VALUE);
|
||||
if (value === null) {
|
||||
// tw: support legacy block
|
||||
if (String(args.VALUE).toLowerCase() === 'last key pressed') {
|
||||
return util.ioQuery('keyboard', 'getLastKeyPressed');
|
||||
}
|
||||
// When the parameter is not found in the most recent procedure
|
||||
// call, the default is always 0.
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
argumentReporterBoolean (args, util) {
|
||||
const value = util.getParam(args.VALUE);
|
||||
if (value === null) {
|
||||
// tw: implement is compiled? and is turbowarp?
|
||||
const lowercaseValue = String(args.VALUE).toLowerCase();
|
||||
if (util.target.runtime.compilerOptions.enabled && lowercaseValue === 'is compiled?') {
|
||||
return true;
|
||||
}
|
||||
if (lowercaseValue === 'is turbowarp?') {
|
||||
return true;
|
||||
}
|
||||
// When the parameter is not found in the most recent procedure
|
||||
// call, the default is always 0.
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3ProcedureBlocks;
|
||||
348
scratch-vm/src/blocks/scratch3_sensing.js
Normal file
348
scratch-vm/src/blocks/scratch3_sensing.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const Cast = require('../util/cast');
|
||||
const Timer = require('../util/timer');
|
||||
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');
|
||||
|
||||
class Scratch3SensingBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
/**
|
||||
* The "answer" block value.
|
||||
* @type {string}
|
||||
*/
|
||||
this._answer = ''; // used by compiler
|
||||
|
||||
/**
|
||||
* The timer utility.
|
||||
* @type {Timer}
|
||||
*/
|
||||
this._timer = new Timer();
|
||||
|
||||
/**
|
||||
* The stored microphone loudness measurement.
|
||||
* @type {number}
|
||||
*/
|
||||
this._cachedLoudness = -1;
|
||||
|
||||
/**
|
||||
* The time of the most recent microphone loudness measurement.
|
||||
* @type {number}
|
||||
*/
|
||||
this._cachedLoudnessTimestamp = 0;
|
||||
|
||||
/**
|
||||
* 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));
|
||||
this.runtime.on('STOP_FOR_TARGET', this._clearTargetQuestions.bind(this));
|
||||
this.runtime.on('RUNTIME_DISPOSED', this._resetAnswer.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block primitives implemented by this package.
|
||||
* @return {object.<string, Function>} Mapping of opcode to Function.
|
||||
*/
|
||||
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_loud: this.isLoud,
|
||||
sensing_askandwait: this.askAndWait,
|
||||
sensing_answer: this.getAnswer,
|
||||
sensing_username: this.getUsername,
|
||||
sensing_userid: () => {} // legacy no-op block
|
||||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
sensing_answer: {
|
||||
getId: () => 'answer'
|
||||
},
|
||||
sensing_mousedown: {
|
||||
getId: () => 'mousedown'
|
||||
},
|
||||
sensing_mousex: {
|
||||
getId: () => 'mousex'
|
||||
},
|
||||
sensing_mousey: {
|
||||
getId: () => 'mousey'
|
||||
},
|
||||
sensing_loudness: {
|
||||
getId: () => 'loudness'
|
||||
},
|
||||
sensing_timer: {
|
||||
getId: () => 'timer'
|
||||
},
|
||||
sensing_dayssince2000: {
|
||||
getId: () => 'dayssince2000'
|
||||
},
|
||||
sensing_current: {
|
||||
// This is different from the default toolbox xml id in order to support
|
||||
// importing multiple monitors from the same opcode from sb2 files,
|
||||
// something that is not currently supported in scratch 3.
|
||||
getId: (_, fields) => getMonitorIdForBlockWithArgs('current', fields) // _${param}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
_clearTargetQuestions (stopTarget) {
|
||||
const currentlyAsking = this._questionList.length > 0 && this._questionList[0][2] === stopTarget;
|
||||
this._questionList = this._questionList.filter(question => (
|
||||
question[2] !== stopTarget
|
||||
));
|
||||
|
||||
if (currentlyAsking) {
|
||||
this.runtime.emit('SAY', stopTarget, 'say', '');
|
||||
if (this._questionList.length > 0) {
|
||||
this._askNextQuestion();
|
||||
} else {
|
||||
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) {
|
||||
return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU);
|
||||
}
|
||||
|
||||
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 {
|
||||
args.DISTANCETOMENU = Cast.toString(args.DISTANCETOMENU);
|
||||
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;
|
||||
if (this.runtime.currentStepTime === null) return -1;
|
||||
|
||||
// Only measure loudness once per step
|
||||
const timeSinceLoudness = this._timer.time() - this._cachedLoudnessTimestamp;
|
||||
if (timeSinceLoudness < this.runtime.currentStepTime) {
|
||||
return this._cachedLoudness;
|
||||
}
|
||||
|
||||
this._cachedLoudnessTimestamp = this._timer.time();
|
||||
this._cachedLoudness = this.runtime.audioEngine.getLoudness();
|
||||
return this._cachedLoudness;
|
||||
}
|
||||
|
||||
isLoud () {
|
||||
return this.getLoudness() > 10;
|
||||
}
|
||||
|
||||
getAttributeOf (args) {
|
||||
let attrTarget;
|
||||
|
||||
if (args.OBJECT === '_stage_') {
|
||||
attrTarget = this.runtime.getTargetForStage();
|
||||
} else {
|
||||
args.OBJECT = Cast.toString(args.OBJECT);
|
||||
attrTarget = this.runtime.getSpriteTargetByName(args.OBJECT);
|
||||
}
|
||||
|
||||
// attrTarget can be undefined if the target does not exist
|
||||
// (e.g. single sprite uploaded from larger project referencing
|
||||
// another sprite that wasn't uploaded)
|
||||
if (!attrTarget) return 0;
|
||||
|
||||
// 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.getCostumes()[attrTarget.currentCostume].name;
|
||||
case 'volume': return attrTarget.volume;
|
||||
}
|
||||
} 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.getCostumes()[attrTarget.currentCostume].name;
|
||||
case 'size': return attrTarget.size;
|
||||
case 'volume': return attrTarget.volume;
|
||||
}
|
||||
}
|
||||
|
||||
// Target variables.
|
||||
const varName = args.PROPERTY;
|
||||
const variable = attrTarget.lookupVariableByNameAndType(varName, '', true);
|
||||
if (variable) {
|
||||
return variable.value;
|
||||
}
|
||||
|
||||
// Otherwise, 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
getUsername (args, util) {
|
||||
return util.ioQuery('userData', 'getUsername');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3SensingBlocks;
|
||||
373
scratch-vm/src/blocks/scratch3_sound.js
Normal file
373
scratch-vm/src/blocks/scratch3_sound.js
Normal file
@@ -0,0 +1,373 @@
|
||||
const MathUtil = require('../util/math-util');
|
||||
const Cast = require('../util/cast');
|
||||
const Clone = require('../util/clone');
|
||||
|
||||
/**
|
||||
* Occluded boolean value to make its use more understandable.
|
||||
* @const {boolean}
|
||||
*/
|
||||
const STORE_WAITING = true;
|
||||
|
||||
class Scratch3SoundBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
this.waitingSounds = {};
|
||||
|
||||
// Clear sound effects on green flag and stop button events.
|
||||
this.stopAllSounds = this.stopAllSounds.bind(this);
|
||||
this._stopWaitingSoundsForTarget = this._stopWaitingSoundsForTarget.bind(this);
|
||||
this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this);
|
||||
if (this.runtime) {
|
||||
this.runtime.on('PROJECT_STOP_ALL', this.stopAllSounds);
|
||||
this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets);
|
||||
this.runtime.on('STOP_FOR_TARGET', this._stopWaitingSoundsForTarget);
|
||||
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 {
|
||||
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: -360, max: 360}, // -3 to 3 octaves
|
||||
pan: {min: -100, max: 100} // 100% left to 100% right
|
||||
};
|
||||
}
|
||||
|
||||
/** The minimum and maximum values for sound effects when miscellaneous limits are removed. */
|
||||
static get LARGER_EFFECT_RANGE () {
|
||||
return {
|
||||
// scratch-audio throws if pitch is too big because some math results in Infinity
|
||||
pitch: {min: -1000, max: 1000},
|
||||
|
||||
// No reason for these to go beyond 100
|
||||
pan: {min: -100, max: 100}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
target.soundEffects = soundState.effects;
|
||||
}
|
||||
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.<string, Function>} 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: {
|
||||
isSpriteSpecific: true,
|
||||
getId: targetId => `${targetId}_volume`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
playSound (args, util) {
|
||||
// Don't return the promise, it's the only difference for AndWait
|
||||
this._playSound(args, util);
|
||||
}
|
||||
|
||||
playSoundAndWait (args, util) {
|
||||
return this._playSound(args, util, STORE_WAITING);
|
||||
}
|
||||
|
||||
_playSound (args, util, storeWaiting) {
|
||||
const index = this._getSoundIndex(args.SOUND_MENU, util);
|
||||
if (index >= 0) {
|
||||
const {target} = util;
|
||||
const {sprite} = target;
|
||||
const {soundId} = sprite.sounds[index];
|
||||
if (sprite.soundBank) {
|
||||
if (storeWaiting === STORE_WAITING) {
|
||||
this._addWaitingSound(target.id, soundId);
|
||||
} else {
|
||||
this._removeWaitingSound(target.id, soundId);
|
||||
}
|
||||
return sprite.soundBank.playSound(target, soundId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addWaitingSound (targetId, soundId) {
|
||||
if (!this.waitingSounds[targetId]) {
|
||||
this.waitingSounds[targetId] = new Set();
|
||||
}
|
||||
this.waitingSounds[targetId].add(soundId);
|
||||
}
|
||||
|
||||
_removeWaitingSound (targetId, soundId) {
|
||||
if (!this.waitingSounds[targetId]) {
|
||||
return;
|
||||
}
|
||||
this.waitingSounds[targetId].delete(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.sprite.soundBank) {
|
||||
target.sprite.soundBank.stopAllSounds(target);
|
||||
if (this.waitingSounds[target.id]) {
|
||||
this.waitingSounds[target.id].clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_stopWaitingSoundsForTarget (target) {
|
||||
if (target.sprite.soundBank) {
|
||||
if (this.waitingSounds[target.id]) {
|
||||
for (const soundId of this.waitingSounds[target.id].values()) {
|
||||
target.sprite.soundBank.stop(target, soundId);
|
||||
}
|
||||
this.waitingSounds[target.id].clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEffect (args, util) {
|
||||
return this._updateEffect(args, util, false);
|
||||
}
|
||||
|
||||
changeEffect (args, util) {
|
||||
return 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 (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) return;
|
||||
|
||||
if (change) {
|
||||
soundState.effects[effect] += value;
|
||||
} else {
|
||||
soundState.effects[effect] = value;
|
||||
}
|
||||
|
||||
const miscLimits = this.runtime.runtimeOptions.miscLimits;
|
||||
const {min, max} = miscLimits ?
|
||||
Scratch3SoundBlocks.EFFECT_RANGE[effect] :
|
||||
Scratch3SoundBlocks.LARGER_EFFECT_RANGE[effect];
|
||||
soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max);
|
||||
|
||||
this._syncEffectsForTarget(util.target);
|
||||
if (miscLimits) {
|
||||
// Yield until the next tick.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Requesting a redraw makes sure that "forever: change pitch by 1" still work but without
|
||||
// yielding unnecessarily in other cases
|
||||
this.runtime.requestRedraw();
|
||||
}
|
||||
|
||||
_syncEffectsForTarget (target) {
|
||||
if (!target || !target.sprite.soundBank) return;
|
||||
target.soundEffects = this._getSoundState(target).effects;
|
||||
|
||||
target.sprite.soundBank.setEffects(target);
|
||||
}
|
||||
|
||||
clearEffects (args, util) {
|
||||
this._clearEffectsForTarget(util.target);
|
||||
}
|
||||
|
||||
_clearEffectsForTarget (target) {
|
||||
const soundState = this._getSoundState(target);
|
||||
for (const effect in soundState.effects) {
|
||||
if (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) continue;
|
||||
soundState.effects[effect] = 0;
|
||||
}
|
||||
this._syncEffectsForTarget(target);
|
||||
}
|
||||
|
||||
_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);
|
||||
return this._updateVolume(volume, util);
|
||||
}
|
||||
|
||||
changeVolume (args, util) {
|
||||
const volume = Cast.toNumber(args.VOLUME) + util.target.volume;
|
||||
return this._updateVolume(volume, util);
|
||||
}
|
||||
|
||||
_updateVolume (volume, util) {
|
||||
volume = MathUtil.clamp(volume, 0, 100);
|
||||
util.target.volume = volume;
|
||||
this._syncEffectsForTarget(util.target);
|
||||
|
||||
if (this.runtime.runtimeOptions.miscLimits) {
|
||||
// Yield until the next tick.
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.runtime.requestRedraw();
|
||||
}
|
||||
|
||||
getVolume (args, util) {
|
||||
return util.target.volume;
|
||||
}
|
||||
|
||||
soundsMenu (args) {
|
||||
return args.SOUND_MENU;
|
||||
}
|
||||
|
||||
beatsMenu (args) {
|
||||
return args.BEATS;
|
||||
}
|
||||
|
||||
effectsMenu (args) {
|
||||
return args.EFFECT;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3SoundBlocks;
|
||||
41
scratch-vm/src/cli/index.js
Normal file
41
scratch-vm/src/cli/index.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const VirtualMachine = require('../index');
|
||||
|
||||
/* eslint-env node */
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
throw new Error('Invalid file');
|
||||
}
|
||||
|
||||
const runProject = async buffer => {
|
||||
const vm = new VirtualMachine();
|
||||
vm.runtime.on('SAY', (target, type, text) => {
|
||||
console.log(text);
|
||||
});
|
||||
vm.setCompatibilityMode(true);
|
||||
vm.clear();
|
||||
await vm.loadProject(buffer);
|
||||
vm.start();
|
||||
vm.greenFlag();
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
let active = 0;
|
||||
const threads = vm.runtime.threads;
|
||||
for (let i = 0; i < threads.length; i++) {
|
||||
if (!threads[i].updateMonitor) {
|
||||
active += 1;
|
||||
}
|
||||
}
|
||||
if (active === 0) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
vm.stopAll();
|
||||
vm.quit();
|
||||
};
|
||||
|
||||
runProject(fs.readFileSync(file));
|
||||
42
scratch-vm/src/compiler/compat-block-utility.js
Normal file
42
scratch-vm/src/compiler/compat-block-utility.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const BlockUtility = require('../engine/block-utility');
|
||||
|
||||
class CompatibilityLayerBlockUtility extends BlockUtility {
|
||||
constructor () {
|
||||
super();
|
||||
this._startedBranch = null;
|
||||
}
|
||||
|
||||
get stackFrame () {
|
||||
return this.thread.compatibilityStackFrame;
|
||||
}
|
||||
|
||||
startBranch (branchNumber, isLoop) {
|
||||
this._startedBranch = [branchNumber, isLoop];
|
||||
}
|
||||
|
||||
startProcedure () {
|
||||
throw new Error('startProcedure is not supported by this BlockUtility');
|
||||
}
|
||||
|
||||
// Parameters are not used by compiled scripts.
|
||||
initParams () {
|
||||
throw new Error('initParams is not supported by this BlockUtility');
|
||||
}
|
||||
pushParam () {
|
||||
throw new Error('pushParam is not supported by this BlockUtility');
|
||||
}
|
||||
getParam () {
|
||||
throw new Error('getParam is not supported by this BlockUtility');
|
||||
}
|
||||
|
||||
init (thread, fakeBlockId, stackFrame) {
|
||||
this.thread = thread;
|
||||
this.sequencer = thread.target.runtime.sequencer;
|
||||
this._startedBranch = null;
|
||||
thread.stack[0] = fakeBlockId;
|
||||
thread.compatibilityStackFrame = stackFrame;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance to be reused.
|
||||
module.exports = new CompatibilityLayerBlockUtility();
|
||||
48
scratch-vm/src/compiler/compat-blocks.js
Normal file
48
scratch-vm/src/compiler/compat-blocks.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview List of blocks to be supported in the compiler compatibility layer.
|
||||
* This is only for native blocks. Extensions should not be listed here.
|
||||
*/
|
||||
|
||||
// Please keep these lists alphabetical.
|
||||
|
||||
const stacked = [
|
||||
'looks_changestretchby',
|
||||
'looks_hideallsprites',
|
||||
'looks_say',
|
||||
'looks_sayforsecs',
|
||||
'looks_setstretchto',
|
||||
'looks_switchbackdroptoandwait',
|
||||
'looks_think',
|
||||
'looks_thinkforsecs',
|
||||
'motion_align_scene',
|
||||
'motion_glidesecstoxy',
|
||||
'motion_glideto',
|
||||
'motion_goto',
|
||||
'motion_pointtowards',
|
||||
'motion_scroll_right',
|
||||
'motion_scroll_up',
|
||||
'sensing_askandwait',
|
||||
'sensing_setdragmode',
|
||||
'sound_changeeffectby',
|
||||
'sound_changevolumeby',
|
||||
'sound_cleareffects',
|
||||
'sound_play',
|
||||
'sound_playuntildone',
|
||||
'sound_seteffectto',
|
||||
'sound_setvolumeto',
|
||||
'sound_stopallsounds'
|
||||
];
|
||||
|
||||
const inputs = [
|
||||
'motion_xscroll',
|
||||
'motion_yscroll',
|
||||
'sensing_loud',
|
||||
'sensing_loudness',
|
||||
'sensing_userid',
|
||||
'sound_volume'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
stacked,
|
||||
inputs
|
||||
};
|
||||
37
scratch-vm/src/compiler/compile.js
Normal file
37
scratch-vm/src/compiler/compile.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const {IRGenerator} = require('./irgen');
|
||||
const JSGenerator = require('./jsgen');
|
||||
|
||||
const compile = thread => {
|
||||
const irGenerator = new IRGenerator(thread);
|
||||
const ir = irGenerator.generate();
|
||||
|
||||
const procedures = {};
|
||||
const target = thread.target;
|
||||
|
||||
const compileScript = script => {
|
||||
if (script.cachedCompileResult) {
|
||||
return script.cachedCompileResult;
|
||||
}
|
||||
|
||||
const compiler = new JSGenerator(script, ir, target);
|
||||
const result = compiler.compile();
|
||||
script.cachedCompileResult = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
const entry = compileScript(ir.entry);
|
||||
|
||||
for (const procedureVariant of Object.keys(ir.procedures)) {
|
||||
const procedureData = ir.procedures[procedureVariant];
|
||||
const procedureTree = compileScript(procedureData);
|
||||
procedures[procedureVariant] = procedureTree;
|
||||
}
|
||||
|
||||
return {
|
||||
startingFunction: entry,
|
||||
procedures,
|
||||
executableHat: ir.entry.executableHat
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = compile;
|
||||
20
scratch-vm/src/compiler/environment.js
Normal file
20
scratch-vm/src/compiler/environment.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable no-eval */
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if the nullish coalescing operator (x ?? y) is supported.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator
|
||||
*/
|
||||
const supportsNullishCoalescing = () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const fn = new Function('undefined ?? 3');
|
||||
// if function construction succeeds, the browser understood the syntax.
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
supportsNullishCoalescing: supportsNullishCoalescing()
|
||||
};
|
||||
109
scratch-vm/src/compiler/intermediate.js
Normal file
109
scratch-vm/src/compiler/intermediate.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview Common intermediates shared amongst parts of the compiler.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An IntermediateScript describes a single script.
|
||||
* Scripts do not necessarily have hats.
|
||||
*/
|
||||
class IntermediateScript {
|
||||
constructor () {
|
||||
/**
|
||||
* The ID of the top block of this script.
|
||||
* @type {string}
|
||||
*/
|
||||
this.topBlockId = null;
|
||||
|
||||
/**
|
||||
* List of nodes that make up this script.
|
||||
* @type {Array|null}
|
||||
*/
|
||||
this.stack = null;
|
||||
|
||||
/**
|
||||
* Whether this script is a procedure.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.isProcedure = false;
|
||||
|
||||
/**
|
||||
* This procedure's variant, if any.
|
||||
* @type {string}
|
||||
*/
|
||||
this.procedureVariant = '';
|
||||
|
||||
/**
|
||||
* This procedure's code, if any.
|
||||
* @type {string}
|
||||
*/
|
||||
this.procedureCode = '';
|
||||
|
||||
/**
|
||||
* List of names of arguments accepted by this function, if it is a procedure.
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.arguments = [];
|
||||
|
||||
/**
|
||||
* Whether this script should be run in warp mode.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.isWarp = false;
|
||||
|
||||
/**
|
||||
* Whether this script can `yield`
|
||||
* If false, this script will be compiled as a regular JavaScript function (function)
|
||||
* If true, this script will be compiled as a generator function (function*)
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.yields = true;
|
||||
|
||||
/**
|
||||
* Whether this script should use the "warp timer"
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.warpTimer = false;
|
||||
|
||||
/**
|
||||
* List of procedure IDs that this script needs.
|
||||
* @readonly
|
||||
*/
|
||||
this.dependedProcedures = [];
|
||||
|
||||
/**
|
||||
* Cached result of compiling this script.
|
||||
* @type {Function|null}
|
||||
*/
|
||||
this.cachedCompileResult = null;
|
||||
|
||||
/**
|
||||
* Whether the top block of this script is an executable hat.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.executableHat = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An IntermediateRepresentation contains scripts.
|
||||
*/
|
||||
class IntermediateRepresentation {
|
||||
constructor () {
|
||||
/**
|
||||
* The entry point of this IR.
|
||||
* @type {IntermediateScript}
|
||||
*/
|
||||
this.entry = null;
|
||||
|
||||
/**
|
||||
* Maps procedure variants to their intermediate script.
|
||||
* @type {Object.<string, IntermediateScript>}
|
||||
*/
|
||||
this.procedures = {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
IntermediateScript,
|
||||
IntermediateRepresentation
|
||||
};
|
||||
2120
scratch-vm/src/compiler/irgen.js
Normal file
2120
scratch-vm/src/compiler/irgen.js
Normal file
File diff suppressed because it is too large
Load Diff
635
scratch-vm/src/compiler/jsexecute.js
Normal file
635
scratch-vm/src/compiler/jsexecute.js
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* @fileoverview Runtime for scripts generated by jsgen
|
||||
*/
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
/* eslint-disable prefer-template */
|
||||
/* eslint-disable valid-jsdoc */
|
||||
/* eslint-disable max-len */
|
||||
|
||||
const globalState = {
|
||||
Timer: require('../util/timer'),
|
||||
Cast: require('../util/cast'),
|
||||
log: require('../util/log'),
|
||||
blockUtility: require('./compat-block-utility'),
|
||||
thread: null
|
||||
};
|
||||
|
||||
let baseRuntime = '';
|
||||
const runtimeFunctions = {};
|
||||
|
||||
/**
|
||||
* Determine whether the current tick is likely stuck.
|
||||
* This implements similar functionality to the warp timer found in Scratch.
|
||||
* @returns {boolean} true if the current tick is likely stuck.
|
||||
*/
|
||||
baseRuntime += `let stuckCounter = 0;
|
||||
const isStuck = () => {
|
||||
// The real time is not checked on every call for performance.
|
||||
stuckCounter++;
|
||||
if (stuckCounter === 100) {
|
||||
stuckCounter = 0;
|
||||
return globalState.thread.target.runtime.sequencer.timer.timeElapsed() > 500;
|
||||
}
|
||||
return false;
|
||||
};`;
|
||||
|
||||
/**
|
||||
* Start hats by opcode.
|
||||
* @param {string} requestedHat The opcode of the hat to start.
|
||||
* @param {*} optMatchFields Fields to match.
|
||||
* @returns {Array} A list of threads that were started.
|
||||
*/
|
||||
runtimeFunctions.startHats = `const startHats = (requestedHat, optMatchFields) => {
|
||||
const thread = globalState.thread;
|
||||
const threads = thread.target.runtime.startHats(requestedHat, optMatchFields);
|
||||
return threads;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Implements "thread waiting", where scripts are halted until all the scripts have finished executing.
|
||||
* @param {Array} threads The list of threads.
|
||||
*/
|
||||
runtimeFunctions.waitThreads = `const waitThreads = function*(threads) {
|
||||
const thread = globalState.thread;
|
||||
const runtime = thread.target.runtime;
|
||||
|
||||
while (true) {
|
||||
// determine whether any threads are running
|
||||
let anyRunning = false;
|
||||
for (let i = 0; i < threads.length; i++) {
|
||||
if (runtime.threads.indexOf(threads[i]) !== -1) {
|
||||
anyRunning = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!anyRunning) {
|
||||
// all threads are finished, can resume
|
||||
return;
|
||||
}
|
||||
|
||||
let allWaiting = true;
|
||||
for (let i = 0; i < threads.length; i++) {
|
||||
if (!runtime.isWaitingThread(threads[i])) {
|
||||
allWaiting = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allWaiting) {
|
||||
thread.status = 3; // STATUS_YIELD_TICK
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
}`;
|
||||
|
||||
/**
|
||||
* waitPromise: Wait until a Promise resolves or rejects before continuing.
|
||||
* @param {Promise} promise The promise to wait for.
|
||||
* @returns {*} the value that the promise resolves to, otherwise undefined if the promise rejects
|
||||
*/
|
||||
|
||||
/**
|
||||
* isPromise: Determine if a value is Promise-like
|
||||
* @param {unknown} promise The value to check
|
||||
* @returns {promise is PromiseLike} True if the value is Promise-like (has a .then())
|
||||
*/
|
||||
|
||||
/**
|
||||
* executeInCompatibilityLayer: Execute a scratch-vm primitive.
|
||||
* @param {*} inputs The inputs to pass to the block.
|
||||
* @param {function} blockFunction The primitive's function.
|
||||
* @param {boolean} useFlags Whether to set flags (hasResumedFromPromise)
|
||||
* @param {string} blockId Block ID to set on the emulated block utility.
|
||||
* @param {*|null} branchInfo Extra information object for CONDITIONAL and LOOP blocks. See createBranchInfo().
|
||||
* @returns {*} the value returned by the block, if any.
|
||||
*/
|
||||
runtimeFunctions.executeInCompatibilityLayer = `let hasResumedFromPromise = false;
|
||||
const waitPromise = function*(promise) {
|
||||
const thread = globalState.thread;
|
||||
let returnValue;
|
||||
|
||||
// enter STATUS_PROMISE_WAIT and yield
|
||||
// this will stop script execution until the promise handlers reset the thread status
|
||||
// because promise handlers might execute immediately, configure thread.status here
|
||||
thread.status = 1; // STATUS_PROMISE_WAIT
|
||||
|
||||
promise
|
||||
.then(value => {
|
||||
returnValue = value;
|
||||
thread.status = 0; // STATUS_RUNNING
|
||||
}, error => {
|
||||
globalState.log.warn('Promise rejected in compiled script:', error);
|
||||
returnValue = '' + error;
|
||||
thread.status = 0; // STATUS_RUNNING
|
||||
});
|
||||
|
||||
yield;
|
||||
|
||||
return returnValue;
|
||||
};
|
||||
const isPromise = value => (
|
||||
// see engine/execute.js
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.then === 'function'
|
||||
);
|
||||
const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, branchInfo) {
|
||||
const thread = globalState.thread;
|
||||
const blockUtility = globalState.blockUtility;
|
||||
const stackFrame = branchInfo ? branchInfo.stackFrame : {};
|
||||
|
||||
const finish = (returnValue) => {
|
||||
if (branchInfo) {
|
||||
if (typeof returnValue === 'undefined' && blockUtility._startedBranch) {
|
||||
branchInfo.isLoop = blockUtility._startedBranch[1];
|
||||
return blockUtility._startedBranch[0];
|
||||
}
|
||||
branchInfo.isLoop = branchInfo.defaultIsLoop;
|
||||
return returnValue;
|
||||
}
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
const executeBlock = () => {
|
||||
blockUtility.init(thread, blockId, stackFrame);
|
||||
return blockFunction(inputs, blockUtility);
|
||||
};
|
||||
|
||||
let returnValue = executeBlock();
|
||||
if (isPromise(returnValue)) {
|
||||
returnValue = finish(yield* waitPromise(returnValue));
|
||||
if (useFlags) hasResumedFromPromise = true;
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) {
|
||||
// Something external is forcing us to stop
|
||||
yield;
|
||||
// Make up a return value because whatever is forcing us to stop can't specify one
|
||||
return '';
|
||||
}
|
||||
|
||||
while (thread.status === 2 /* STATUS_YIELD */ || thread.status === 3 /* STATUS_YIELD_TICK */) {
|
||||
// Yielded threads will run next iteration.
|
||||
if (thread.status === 2 /* STATUS_YIELD */) {
|
||||
thread.status = 0; // STATUS_RUNNING
|
||||
// Yield back to the event loop when stuck or not in warp mode.
|
||||
if (!isWarp || isStuck()) {
|
||||
yield;
|
||||
}
|
||||
} else {
|
||||
// status is STATUS_YIELD_TICK, always yield to the event loop
|
||||
yield;
|
||||
}
|
||||
|
||||
returnValue = executeBlock();
|
||||
if (isPromise(returnValue)) {
|
||||
returnValue = finish(yield* waitPromise(returnValue));
|
||||
if (useFlags) hasResumedFromPromise = true;
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) {
|
||||
yield;
|
||||
return finish('');
|
||||
}
|
||||
}
|
||||
|
||||
return finish(returnValue);
|
||||
}`;
|
||||
|
||||
/**
|
||||
* @param {boolean} isLoop True if the block is a LOOP by default (can be overridden by startBranch() call)
|
||||
* @returns {unknown} Branch info object for compatibility layer.
|
||||
*/
|
||||
runtimeFunctions.createBranchInfo = `const createBranchInfo = (isLoop) => ({
|
||||
defaultIsLoop: isLoop,
|
||||
isLoop: false,
|
||||
branch: 0,
|
||||
stackFrame: {}
|
||||
});`;
|
||||
|
||||
/**
|
||||
* End the current script.
|
||||
*/
|
||||
runtimeFunctions.retire = `const retire = () => {
|
||||
const thread = globalState.thread;
|
||||
thread.target.runtime.sequencer.retireThread(thread);
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Scratch cast to boolean.
|
||||
* Similar to Cast.toBoolean()
|
||||
* @param {*} value The value to cast
|
||||
* @returns {boolean} The value cast to a boolean
|
||||
*/
|
||||
runtimeFunctions.toBoolean = `const toBoolean = value => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value === '' || value === '0' || value.toLowerCase() === 'false') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return !!value;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* If a number is very close to a whole number, round to that whole number.
|
||||
* @param {number} value Value to round
|
||||
* @returns {number} Rounded number or original number
|
||||
*/
|
||||
runtimeFunctions.limitPrecision = `const limitPrecision = value => {
|
||||
const rounded = Math.round(value);
|
||||
const delta = value - rounded;
|
||||
return (Math.abs(delta) < 1e-9) ? rounded : value;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Used internally by the compare family of function.
|
||||
* See similar method in cast.js.
|
||||
* @param {*} val A value that evaluates to 0 in JS string-to-number conversation such as empty string, 0, or tab.
|
||||
* @returns {boolean} True if the value should not be treated as the number zero.
|
||||
*/
|
||||
baseRuntime += `const isNotActuallyZero = val => {
|
||||
if (typeof val !== 'string') return false;
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const code = val.charCodeAt(i);
|
||||
if (code === 48 || code === 9) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};`;
|
||||
|
||||
/**
|
||||
* Determine if two values are equal.
|
||||
* @param {*} v1 First value
|
||||
* @param {*} v2 Second value
|
||||
* @returns {boolean} true if v1 is equal to v2
|
||||
*/
|
||||
baseRuntime += `const compareEqualSlow = (v1, v2) => {
|
||||
const n1 = +v1;
|
||||
if (isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
|
||||
const n2 = +v2;
|
||||
if (isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
|
||||
return n1 === n2;
|
||||
};
|
||||
const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) && !isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`;
|
||||
|
||||
/**
|
||||
* Determine if one value is greater than another.
|
||||
* @param {*} v1 First value
|
||||
* @param {*} v2 Second value
|
||||
* @returns {boolean} true if v1 is greater than v2
|
||||
*/
|
||||
runtimeFunctions.compareGreaterThan = `const compareGreaterThanSlow = (v1, v2) => {
|
||||
let n1 = +v1;
|
||||
let n2 = +v2;
|
||||
if (n1 === 0 && isNotActuallyZero(v1)) {
|
||||
n1 = NaN;
|
||||
} else if (n2 === 0 && isNotActuallyZero(v2)) {
|
||||
n2 = NaN;
|
||||
}
|
||||
if (isNaN(n1) || isNaN(n2)) {
|
||||
const s1 = ('' + v1).toLowerCase();
|
||||
const s2 = ('' + v2).toLowerCase();
|
||||
return s1 > s2;
|
||||
}
|
||||
return n1 > n2;
|
||||
};
|
||||
const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`;
|
||||
|
||||
/**
|
||||
* Determine if one value is less than another.
|
||||
* @param {*} v1 First value
|
||||
* @param {*} v2 Second value
|
||||
* @returns {boolean} true if v1 is less than v2
|
||||
*/
|
||||
runtimeFunctions.compareLessThan = `const compareLessThanSlow = (v1, v2) => {
|
||||
let n1 = +v1;
|
||||
let n2 = +v2;
|
||||
if (n1 === 0 && isNotActuallyZero(v1)) {
|
||||
n1 = NaN;
|
||||
} else if (n2 === 0 && isNotActuallyZero(v2)) {
|
||||
n2 = NaN;
|
||||
}
|
||||
if (isNaN(n1) || isNaN(n2)) {
|
||||
const s1 = ('' + v1).toLowerCase();
|
||||
const s2 = ('' + v2).toLowerCase();
|
||||
return s1 < s2;
|
||||
}
|
||||
return n1 < n2;
|
||||
};
|
||||
const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`;
|
||||
|
||||
/**
|
||||
* Generate a random integer.
|
||||
* @param {number} low Lower bound
|
||||
* @param {number} high Upper bound
|
||||
* @returns {number} A random integer between low and high, inclusive.
|
||||
*/
|
||||
runtimeFunctions.randomInt = `const randomInt = (low, high) => low + Math.floor(Math.random() * ((high + 1) - low))`;
|
||||
|
||||
/**
|
||||
* Generate a random float.
|
||||
* @param {number} low Lower bound
|
||||
* @param {number} high Upper bound
|
||||
* @returns {number} A random floating point number between low and high.
|
||||
*/
|
||||
runtimeFunctions.randomFloat = `const randomFloat = (low, high) => (Math.random() * (high - low)) + low`;
|
||||
|
||||
/**
|
||||
* Create and start a timer.
|
||||
* @returns {Timer} A started timer
|
||||
*/
|
||||
runtimeFunctions.timer = `const timer = () => {
|
||||
const t = new globalState.Timer({
|
||||
now: () => globalState.thread.target.runtime.currentMSecs
|
||||
});
|
||||
t.start();
|
||||
return t;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Returns the amount of days since January 1st, 2000.
|
||||
* @returns {number} Days since 2000.
|
||||
*/
|
||||
// Date.UTC(2000, 0, 1) === 946684800000
|
||||
// Hardcoding it is marginally faster
|
||||
runtimeFunctions.daysSince2000 = `const daysSince2000 = () => (Date.now() - 946684800000) / (24 * 60 * 60 * 1000)`;
|
||||
|
||||
/**
|
||||
* Determine distance to a sprite or point.
|
||||
* @param {string} menu The name of the sprite or location to find.
|
||||
* @returns {number} Distance to the point, or 10000 if it cannot be calculated.
|
||||
*/
|
||||
runtimeFunctions.distance = `const distance = menu => {
|
||||
const thread = globalState.thread;
|
||||
if (thread.target.isStage) return 10000;
|
||||
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
if (menu === '_mouse_') {
|
||||
targetX = thread.target.runtime.ioDevices.mouse.getScratchX();
|
||||
targetY = thread.target.runtime.ioDevices.mouse.getScratchY();
|
||||
} else {
|
||||
const distTarget = thread.target.runtime.getSpriteTargetByName(menu);
|
||||
if (!distTarget) return 10000;
|
||||
targetX = distTarget.x;
|
||||
targetY = distTarget.y;
|
||||
}
|
||||
|
||||
const dx = thread.target.x - targetX;
|
||||
const dy = thread.target.y - targetY;
|
||||
return Math.sqrt((dx * dx) + (dy * dy));
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Convert a Scratch list index to a JavaScript list index.
|
||||
* "all" is not considered as a list index.
|
||||
* Similar to Cast.toListIndex()
|
||||
* @param {number} index Scratch list index.
|
||||
* @param {number} length Length of the list.
|
||||
* @returns {number} 0 based list index, or -1 if invalid.
|
||||
*/
|
||||
baseRuntime += `const listIndexSlow = (index, length) => {
|
||||
if (index === 'last') {
|
||||
return length - 1;
|
||||
} else if (index === 'random' || index === 'any') {
|
||||
if (length > 0) {
|
||||
return (Math.random() * length) | 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
index = (+index || 0) | 0;
|
||||
if (index < 1 || index > length) {
|
||||
return -1;
|
||||
}
|
||||
return index - 1;
|
||||
};
|
||||
const listIndex = (index, length) => {
|
||||
if (typeof index !== 'number') {
|
||||
return listIndexSlow(index, length);
|
||||
}
|
||||
index = index | 0;
|
||||
return index < 1 || index > length ? -1 : index - 1;
|
||||
};`;
|
||||
|
||||
/**
|
||||
* Get a value from a list.
|
||||
* @param {Array} list The list
|
||||
* @param {*} idx The 1-indexed index in the list.
|
||||
* @returns {*} The list item, otherwise empty string if it does not exist.
|
||||
*/
|
||||
runtimeFunctions.listGet = `const listGet = (list, idx) => {
|
||||
const index = listIndex(idx, list.length);
|
||||
if (index === -1) {
|
||||
return '';
|
||||
}
|
||||
return list[index];
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Replace a value in a list.
|
||||
* @param {import('../engine/variable')} list The list
|
||||
* @param {*} idx List index, Scratch style.
|
||||
* @param {*} value The new value.
|
||||
*/
|
||||
runtimeFunctions.listReplace = `const listReplace = (list, idx, value) => {
|
||||
const index = listIndex(idx, list.value.length);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
list.value[index] = value;
|
||||
list._monitorUpToDate = false;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Insert a value in a list.
|
||||
* @param {import('../engine/variable')} list The list.
|
||||
* @param {*} idx The Scratch index in the list.
|
||||
* @param {*} value The value to insert.
|
||||
*/
|
||||
runtimeFunctions.listInsert = `const listInsert = (list, idx, value) => {
|
||||
const index = listIndex(idx, list.value.length + 1);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
list.value.splice(index, 0, value);
|
||||
list._monitorUpToDate = false;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Delete a value from a list.
|
||||
* @param {import('../engine/variable')} list The list.
|
||||
* @param {*} idx The Scratch index in the list.
|
||||
*/
|
||||
runtimeFunctions.listDelete = `const listDelete = (list, idx) => {
|
||||
if (idx === 'all') {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
const index = listIndex(idx, list.value.length);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
list.value.splice(index, 1);
|
||||
list._monitorUpToDate = false;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Return whether a list contains a value.
|
||||
* @param {import('../engine/variable')} list The list.
|
||||
* @param {*} item The value to search for.
|
||||
* @returns {boolean} True if the list contains the item
|
||||
*/
|
||||
runtimeFunctions.listContains = `const listContains = (list, item) => {
|
||||
// TODO: evaluate whether indexOf is worthwhile here
|
||||
if (list.value.indexOf(item) !== -1) {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
if (compareEqual(list.value[i], item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Find the 1-indexed index of an item in a list.
|
||||
* @param {import('../engine/variable')} list The list.
|
||||
* @param {*} item The item to search for
|
||||
* @returns {number} The 1-indexed index of the item in the list, otherwise 0
|
||||
*/
|
||||
runtimeFunctions.listIndexOf = `const listIndexOf = (list, item) => {
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
if (compareEqual(list.value[i], item)) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Get the stringified form of a list.
|
||||
* @param {import('../engine/variable')} list The list.
|
||||
* @returns {string} Stringified form of the list.
|
||||
*/
|
||||
runtimeFunctions.listContents = `const listContents = list => {
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
const listItem = list.value[i];
|
||||
// this is an intentional break from what scratch 3 does to address our automatic string -> number conversions
|
||||
// it fixes more than it breaks
|
||||
if ((listItem + '').length !== 1) {
|
||||
return list.value.join(' ');
|
||||
}
|
||||
}
|
||||
return list.value.join('');
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Convert a color to an RGB list
|
||||
* @param {*} color The color value to convert
|
||||
* @return {Array.<number>} [r,g,b], values between 0-255.
|
||||
*/
|
||||
runtimeFunctions.colorToList = `const colorToList = color => globalState.Cast.toRgbColorList(color)`;
|
||||
|
||||
/**
|
||||
* Implements Scratch modulo (floored division instead of truncated division)
|
||||
* @param {number} n Number
|
||||
* @param {number} modulus Base
|
||||
* @returns {number} n % modulus (floored division)
|
||||
*/
|
||||
runtimeFunctions.mod = `const mod = (n, modulus) => {
|
||||
let result = n % modulus;
|
||||
if (result / modulus < 0) result += modulus;
|
||||
return result;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Implements Scratch tangent.
|
||||
* @param {number} angle Angle in degrees.
|
||||
* @returns {number} value of tangent or Infinity or -Infinity
|
||||
*/
|
||||
runtimeFunctions.tan = `const tan = (angle) => {
|
||||
switch (angle % 360) {
|
||||
case -270: case 90: return Infinity;
|
||||
case -90: case 270: return -Infinity;
|
||||
}
|
||||
return Math.round(Math.tan((Math.PI * angle) / 180) * 1e10) / 1e10;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* @param {function} callback The function to run
|
||||
* @param {...unknown} args The arguments to pass to the function
|
||||
* @returns {unknown} A generator that will yield once then call the function and return its value.
|
||||
*/
|
||||
runtimeFunctions.yieldThenCall = `const yieldThenCall = function* (callback, ...args) {
|
||||
yield;
|
||||
return callback(...args);
|
||||
}`;
|
||||
|
||||
/**
|
||||
* @param {function} callback The generator function to run
|
||||
* @param {...unknown} args The arguments to pass to the generator function
|
||||
* @returns {unknown} A generator that will yield once then delegate to the generator function and return its value.
|
||||
*/
|
||||
runtimeFunctions.yieldThenCallGenerator = `const yieldThenCallGenerator = function* (callback, ...args) {
|
||||
yield;
|
||||
return yield* callback(...args);
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Step a compiled thread.
|
||||
* @param {Thread} thread The thread to step.
|
||||
*/
|
||||
const execute = thread => {
|
||||
globalState.thread = thread;
|
||||
thread.generator.next();
|
||||
};
|
||||
|
||||
const threadStack = [];
|
||||
const saveGlobalState = () => {
|
||||
threadStack.push(globalState.thread);
|
||||
};
|
||||
const restoreGlobalState = () => {
|
||||
globalState.thread = threadStack.pop();
|
||||
};
|
||||
|
||||
const insertRuntime = source => {
|
||||
let result = baseRuntime;
|
||||
for (const functionName of Object.keys(runtimeFunctions)) {
|
||||
if (source.includes(functionName)) {
|
||||
result += `${runtimeFunctions[functionName]};`;
|
||||
}
|
||||
}
|
||||
result += `return ${source}`;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate arbitrary JS in the context of the runtime.
|
||||
* @param {string} source The string to evaluate.
|
||||
* @returns {*} The result of evaluating the string.
|
||||
*/
|
||||
const scopedEval = source => {
|
||||
const withRuntime = insertRuntime(source);
|
||||
try {
|
||||
return new Function('globalState', withRuntime)(globalState);
|
||||
} catch (e) {
|
||||
globalState.log.error('was unable to compile script', withRuntime);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
execute.scopedEval = scopedEval;
|
||||
execute.runtimeFunctions = runtimeFunctions;
|
||||
execute.saveGlobalState = saveGlobalState;
|
||||
execute.restoreGlobalState = restoreGlobalState;
|
||||
|
||||
module.exports = execute;
|
||||
1668
scratch-vm/src/compiler/jsgen.js
Normal file
1668
scratch-vm/src/compiler/jsgen.js
Normal file
File diff suppressed because it is too large
Load Diff
21
scratch-vm/src/compiler/variable-pool.js
Normal file
21
scratch-vm/src/compiler/variable-pool.js
Normal file
@@ -0,0 +1,21 @@
|
||||
class VariablePool {
|
||||
/**
|
||||
* @param {string} prefix The prefix at the start of the variable name.
|
||||
*/
|
||||
constructor (prefix) {
|
||||
if (prefix.trim().length === 0) {
|
||||
throw new Error('prefix cannot be empty');
|
||||
}
|
||||
this.prefix = prefix;
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this.count = 0;
|
||||
}
|
||||
|
||||
next () {
|
||||
return `${this.prefix}${this.count++}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VariablePool;
|
||||
143
scratch-vm/src/dispatch/central-dispatch.js
Normal file
143
scratch-vm/src/dispatch/central-dispatch.js
Normal file
@@ -0,0 +1,143 @@
|
||||
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.<Worker|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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously call a particular method on a particular service provided locally.
|
||||
* Calling this function on a remote service will fail.
|
||||
* @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 {*} - the return value of the service method.
|
||||
*/
|
||||
callSync (service, method, ...args) {
|
||||
const {provider, isRemote} = this._getServiceProvider(service);
|
||||
if (provider) {
|
||||
if (isRemote) {
|
||||
throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`);
|
||||
}
|
||||
|
||||
// TODO: verify correct `this` after switching from apply to spread
|
||||
// eslint-disable-next-line prefer-spread
|
||||
return provider[method].apply(provider, args);
|
||||
}
|
||||
throw new Error(`Provider not found for service: ${service}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously 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.
|
||||
*/
|
||||
setServiceSync (service, provider) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.services, service)) {
|
||||
log.warn(`Central dispatch replacing existing service provider for ${service}`);
|
||||
}
|
||||
this.services[service] = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.setServiceSync(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: ${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: Boolean((this.workerClass && provider instanceof this.workerClass) || provider.isRemote)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
239
scratch-vm/src/dispatch/shared-dispatch.js
Normal file
239
scratch-vm/src/dispatch/shared-dispatch.js
Normal file
@@ -0,0 +1,239 @@
|
||||
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[]>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// TODO: verify correct `this` after switching from apply to spread
|
||||
// eslint-disable-next-line prefer-spread
|
||||
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 */
|
||||
// tw: upstream's logic is broken
|
||||
// Args is actually a 3 length list of [args, util, real block info]
|
||||
// We only want to send args. The others will throw errors when they try to be cloned
|
||||
if ((args.length > 0) && (typeof args[args.length - 1].func === 'function')) {
|
||||
args.pop();
|
||||
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: ${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: `${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;
|
||||
113
scratch-vm/src/dispatch/worker-dispatch.js
Normal file
113
scratch-vm/src/dispatch/worker-dispatch.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const SharedDispatch = require('./shared-dispatch');
|
||||
|
||||
const log = require('../util/log');
|
||||
const {centralDispatchService} = require('../extension-support/tw-extension-worker-context');
|
||||
|
||||
/**
|
||||
* 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, centralDispatchService);
|
||||
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 (Object.prototype.hasOwnProperty.call(this.services, service)) {
|
||||
log.warn(`Worker dispatch replacing existing service provider for ${service}`);
|
||||
}
|
||||
this.services[service] = provider;
|
||||
return this.waitForConnection.then(() => (
|
||||
this._remoteCall(centralDispatchService, '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 || centralDispatchService,
|
||||
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();
|
||||
176
scratch-vm/src/engine/adapter.js
Normal file
176
scratch-vm/src/engine/adapter.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const mutationAdapter = require('./mutation-adapter');
|
||||
const html = require('htmlparser2');
|
||||
const uid = require('../util/uid');
|
||||
|
||||
/**
|
||||
* 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 {?string} parent Parent block ID.
|
||||
* @return {undefined}
|
||||
*/
|
||||
const domToBlock = function (blockDOM, blocks, isTopBlock, parent) {
|
||||
if (!blockDOM.attribs.id) {
|
||||
blockDOM.attribs.id = uid();
|
||||
}
|
||||
|
||||
// Block skeleton.
|
||||
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.
|
||||
fields: {}, // Fields on this block and their values.
|
||||
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.
|
||||
x: blockDOM.attribs.x, // X position of script, if top-level.
|
||||
y: blockDOM.attribs.y // Y position of script, if top-level.
|
||||
};
|
||||
|
||||
// Add the block to the representation tree.
|
||||
blocks[block.id] = block;
|
||||
|
||||
// Process XML children and find enclosed blocks, fields, etc.
|
||||
for (let i = 0; i < blockDOM.children.length; i++) {
|
||||
const xmlChild = blockDOM.children[i];
|
||||
// Enclosed blocks and shadows
|
||||
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;
|
||||
}
|
||||
const grandChildNodeName = grandChildNode.name.toLowerCase();
|
||||
if (grandChildNodeName === 'block') {
|
||||
childBlockNode = grandChildNode;
|
||||
} else if (grandChildNodeName === 'shadow') {
|
||||
childShadowNode = grandChildNode;
|
||||
}
|
||||
}
|
||||
|
||||
// Use shadow block only if there's no real block node.
|
||||
if (!childBlockNode && childShadowNode) {
|
||||
childBlockNode = childShadowNode;
|
||||
}
|
||||
|
||||
// Not all Blockly-type blocks are handled here,
|
||||
// as we won't be using all of them for Scratch.
|
||||
switch (xmlChild.name.toLowerCase()) {
|
||||
case 'field':
|
||||
{
|
||||
// Add the field to this block.
|
||||
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 {
|
||||
// If the child of the field with a data property
|
||||
// doesn't exist, set the data to an empty string.
|
||||
fieldData = '';
|
||||
}
|
||||
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 'comment':
|
||||
{
|
||||
block.comment = xmlChild.attribs.id;
|
||||
break;
|
||||
}
|
||||
case 'value':
|
||||
case 'statement':
|
||||
{
|
||||
// Recursively generate block structure for input block.
|
||||
domToBlock(childBlockNode, blocks, false, block.id);
|
||||
if (childShadowNode && childBlockNode !== childShadowNode) {
|
||||
// Also generate the shadow block.
|
||||
domToBlock(childShadowNode, blocks, false, block.id);
|
||||
}
|
||||
// Link this block's input to the child block.
|
||||
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;
|
||||
}
|
||||
// Recursively generate block structure for next block.
|
||||
domToBlock(childBlockNode, blocks, false, block.id);
|
||||
// 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.<object>} 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 (!Object.prototype.hasOwnProperty.call(blocks, 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` or `Blockly.events.endDrag`
|
||||
* @return {Array.<object>} 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;
|
||||
242
scratch-vm/src/engine/block-utility.js
Normal file
242
scratch-vm/src/engine/block-utility.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const Thread = require('./thread');
|
||||
const Timer = require('../util/timer');
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
this._nowObj = {
|
||||
now: () => this.sequencer.runtime.currentMSecs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the runtime's currentMSecs value as a timestamp value for now
|
||||
* This is useful in some cases where we need compatibility with Scratch 2
|
||||
* @type {function}
|
||||
*/
|
||||
get nowObj () {
|
||||
if (this.runtime) {
|
||||
return this._nowObj;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack frame used by loop and other blocks to track internal state.
|
||||
* @type {object}
|
||||
*/
|
||||
get stackFrame () {
|
||||
const frame = this.thread.peekStackFrame();
|
||||
if (frame.executionContext === null) {
|
||||
frame.executionContext = {};
|
||||
}
|
||||
return frame.executionContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the stack timer and return a boolean based on whether it has finished or not.
|
||||
* @return {boolean} - true if the stack timer has finished.
|
||||
*/
|
||||
stackTimerFinished () {
|
||||
const timeElapsed = this.stackFrame.timer.timeElapsed();
|
||||
if (timeElapsed < this.stackFrame.duration) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the stack timer needs initialization.
|
||||
* @return {boolean} - true if the stack timer needs to be initialized.
|
||||
*/
|
||||
stackTimerNeedsInit () {
|
||||
return !this.stackFrame.timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start a stack timer
|
||||
* @param {number} duration - a duration in milliseconds to set the timer for.
|
||||
*/
|
||||
startStackTimer (duration) {
|
||||
if (this.nowObj) {
|
||||
this.stackFrame.timer = new Timer(this.nowObj);
|
||||
} else {
|
||||
this.stackFrame.timer = new Timer();
|
||||
}
|
||||
this.stackFrame.timer.start();
|
||||
this.stackFrame.duration = duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the thread to yield.
|
||||
*/
|
||||
yield () {
|
||||
this.thread.status = Thread.STATUS_YIELD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the thread to yield until the next tick of the runtime.
|
||||
*/
|
||||
yieldTick () {
|
||||
this.thread.status = Thread.STATUS_YIELD_TICK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 and ids of parameters for the given procedure.
|
||||
* @param {string} procedureCode Procedure code for procedure to query.
|
||||
* @return {Array.<string>} List of param names for a procedure.
|
||||
*/
|
||||
getProcedureParamNamesAndIds (procedureCode) {
|
||||
return this.thread.target.blocks.getProcedureParamNamesAndIds(procedureCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get names, ids, and defaults of parameters for the given procedure.
|
||||
* @param {string} procedureCode Procedure code for procedure to query.
|
||||
* @return {Array.<string>} List of param names for a procedure.
|
||||
*/
|
||||
getProcedureParamNamesIdsAndDefaults (procedureCode) {
|
||||
return this.thread.target.blocks.getProcedureParamNamesIdsAndDefaults(procedureCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize procedure parameters in the thread before pushing parameters.
|
||||
*/
|
||||
initParams () {
|
||||
this.thread.initParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.<Thread>} List of threads started by this function.
|
||||
*/
|
||||
startHats (requestedHat, optMatchFields, optTarget) {
|
||||
// Store thread and sequencer to ensure we can return to the calling block's context.
|
||||
// startHats may execute further blocks and dirty the BlockUtility's execution context
|
||||
// and confuse the calling block when we return to it.
|
||||
const callerThread = this.thread;
|
||||
const callerSequencer = this.sequencer;
|
||||
const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget);
|
||||
|
||||
// Restore thread and sequencer to prior values before we return to the calling block.
|
||||
this.thread = callerThread;
|
||||
this.sequencer = callerSequencer;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
// TODO: verify correct `this` after switching from apply to spread
|
||||
// eslint-disable-next-line prefer-spread
|
||||
return devObject[func].apply(devObject, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BlockUtility;
|
||||
19
scratch-vm/src/engine/blocks-execute-cache.js
Normal file
19
scratch-vm/src/engine/blocks-execute-cache.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* Access point for private method shared between blocks.js and execute.js for
|
||||
* caching execute information.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A private method shared with execute to build an object containing the block
|
||||
* information execute needs and that is reset when other cached Blocks info is
|
||||
* reset.
|
||||
* @param {Blocks} blocks Blocks containing the expected blockId
|
||||
* @param {string} blockId blockId for the desired execute cache
|
||||
*/
|
||||
exports.getCached = function () {
|
||||
throw new Error('blocks.js has not initialized BlocksExecuteCache');
|
||||
};
|
||||
|
||||
// Call after the default throwing getCached is assigned for Blocks to replace.
|
||||
require('./blocks');
|
||||
78
scratch-vm/src/engine/blocks-runtime-cache.js
Normal file
78
scratch-vm/src/engine/blocks-runtime-cache.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* The BlocksRuntimeCache caches data about the top block of scripts so that
|
||||
* Runtime can iterate a targeted opcode and iterate the returned set faster.
|
||||
* Many top blocks need to match fields as well as opcode, since that matching
|
||||
* compares strings in uppercase we can go ahead and uppercase the cached value
|
||||
* so we don't need to in the future.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A set of cached data about the top block of a script.
|
||||
* @param {Blocks} container - Container holding the block and related data
|
||||
* @param {string} blockId - Id for whose block data is cached in this instance
|
||||
*/
|
||||
class RuntimeScriptCache {
|
||||
constructor (container, blockId) {
|
||||
/**
|
||||
* Container with block data for blockId.
|
||||
* @type {Blocks}
|
||||
*/
|
||||
this.container = container;
|
||||
|
||||
/**
|
||||
* ID for block this instance caches.
|
||||
* @type {string}
|
||||
*/
|
||||
this.blockId = blockId;
|
||||
|
||||
const block = container.getBlock(blockId);
|
||||
const fields = container.getFields(block);
|
||||
|
||||
/**
|
||||
* Formatted fields or fields of input blocks ready for comparison in
|
||||
* runtime.
|
||||
*
|
||||
* This is a clone of parts of the targeted blocks. Changes to these
|
||||
* clones are limited to copies under RuntimeScriptCache and will not
|
||||
* appear in the original blocks in their container. This copy is
|
||||
* modified changing the case of strings to uppercase. These uppercase
|
||||
* values will be compared later by the VM.
|
||||
* @type {object}
|
||||
*/
|
||||
this.fieldsOfInputs = Object.assign({}, fields);
|
||||
if (Object.keys(fields).length === 0) {
|
||||
const inputs = container.getInputs(block);
|
||||
for (const input in inputs) {
|
||||
if (!Object.prototype.hasOwnProperty.call(inputs, input)) continue;
|
||||
const id = inputs[input].block;
|
||||
const inputBlock = container.getBlock(id);
|
||||
const inputFields = container.getFields(inputBlock);
|
||||
Object.assign(this.fieldsOfInputs, inputFields);
|
||||
}
|
||||
}
|
||||
for (const key in this.fieldsOfInputs) {
|
||||
const field = this.fieldsOfInputs[key] = Object.assign({}, this.fieldsOfInputs[key]);
|
||||
if (field.value.toUpperCase) {
|
||||
field.value = field.value.toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of scripts from a block container prefiltered to match opcode.
|
||||
* @param {Blocks} container - Container of blocks
|
||||
* @param {string} opcode - Opcode to filter top blocks by
|
||||
*/
|
||||
exports.getScripts = function () {
|
||||
throw new Error('blocks.js has not initialized BlocksRuntimeCache');
|
||||
};
|
||||
|
||||
/**
|
||||
* Exposed RuntimeScriptCache class used by integration in blocks.js.
|
||||
* @private
|
||||
*/
|
||||
exports._RuntimeScriptCache = RuntimeScriptCache;
|
||||
|
||||
require('./blocks');
|
||||
1426
scratch-vm/src/engine/blocks.js
Normal file
1426
scratch-vm/src/engine/blocks.js
Normal file
File diff suppressed because it is too large
Load Diff
56
scratch-vm/src/engine/comment.js
Normal file
56
scratch-vm/src/engine/comment.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* Object representing a Scratch Comment (block or workspace).
|
||||
*/
|
||||
|
||||
const uid = require('../util/uid');
|
||||
const xmlEscape = require('../util/xml-escape');
|
||||
|
||||
class Comment {
|
||||
/**
|
||||
* @param {string} id Id of the comment.
|
||||
* @param {string} text Text content of the comment.
|
||||
* @param {number} x X position of the comment on the workspace.
|
||||
* @param {number} y Y position of the comment on the workspace.
|
||||
* @param {number} width The width of the comment when it is full size.
|
||||
* @param {number} height The height of the comment when it is full size.
|
||||
* @param {boolean} minimized Whether the comment is minimized.
|
||||
* @constructor
|
||||
*/
|
||||
constructor (id, text, x, y, width, height, minimized) {
|
||||
this.id = id || uid();
|
||||
this.text = text;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = Math.max(Number(width), Comment.MIN_WIDTH);
|
||||
this.height = Math.max(Number(height), Comment.MIN_HEIGHT);
|
||||
this.minimized = minimized || false;
|
||||
this.blockId = null;
|
||||
}
|
||||
|
||||
toXML () {
|
||||
return `<comment id="${this.id}" x="${this.x}" y="${
|
||||
this.y}" w="${this.width}" h="${this.height}" pinned="${
|
||||
this.blockId !== null}" minimized="${this.minimized}">${xmlEscape(this.text)}</comment>`;
|
||||
}
|
||||
|
||||
// TODO choose min and defaults for width and height
|
||||
static get MIN_WIDTH () {
|
||||
return 20;
|
||||
}
|
||||
|
||||
static get MIN_HEIGHT () {
|
||||
return 20;
|
||||
}
|
||||
|
||||
static get DEFAULT_WIDTH () {
|
||||
return 100;
|
||||
}
|
||||
|
||||
static get DEFAULT_HEIGHT () {
|
||||
return 100;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Comment;
|
||||
603
scratch-vm/src/engine/execute.js
Normal file
603
scratch-vm/src/engine/execute.js
Normal file
@@ -0,0 +1,603 @@
|
||||
const BlockUtility = require('./block-utility');
|
||||
const BlocksExecuteCache = require('./blocks-execute-cache');
|
||||
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.
|
||||
*/
|
||||
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, blockCached, lastOperation) {
|
||||
const currentBlockId = blockCached.id;
|
||||
const opcode = blockCached.opcode;
|
||||
const isHat = blockCached._isHat;
|
||||
const isConditional = blockCached._isConditional;
|
||||
const isLoop = blockCached._isLoop;
|
||||
|
||||
thread.pushReportedValue(resolvedValue);
|
||||
if (isHat) {
|
||||
// Hat predicate was evaluated.
|
||||
if (thread.stackClick) {
|
||||
thread.status = Thread.STATUS_RUNNING;
|
||||
} else if (sequencer.runtime.getIsEdgeActivatedHat(opcode)) {
|
||||
// If this is an edge-activated hat, only proceed if the value is
|
||||
// true and used to be false, or the stack was activated explicitly
|
||||
// via stack click
|
||||
const hasOldEdgeValue = thread.target.hasEdgeActivatedValue(currentBlockId);
|
||||
const oldEdgeValue = thread.target.updateEdgeActivatedValue(
|
||||
currentBlockId,
|
||||
resolvedValue
|
||||
);
|
||||
|
||||
const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue;
|
||||
if (edgeWasActivated) {
|
||||
thread.status = Thread.STATUS_RUNNING;
|
||||
} else {
|
||||
sequencer.retireThread(thread);
|
||||
}
|
||||
} else if (resolvedValue) {
|
||||
// Predicate returned true: allow the script to run.
|
||||
thread.status = Thread.STATUS_RUNNING;
|
||||
} else {
|
||||
// Predicate returned false: do not allow script to run
|
||||
sequencer.retireThread(thread);
|
||||
}
|
||||
} else if ((isConditional || isLoop) && typeof resolvedValue !== 'undefined') {
|
||||
sequencer.stepToBranch(thread, cast.toNumber(resolvedValue), isLoop);
|
||||
} else {
|
||||
// In a non-hat, report the value visually if necessary if
|
||||
// at the top of the thread stack.
|
||||
if (lastOperation && 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: resolvedValue
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Finished any yields.
|
||||
thread.status = Thread.STATUS_RUNNING;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromiseResolution = (resolvedValue, sequencer, thread, blockCached, lastOperation) => {
|
||||
handleReport(resolvedValue, sequencer, thread, blockCached, lastOperation);
|
||||
// If it's a command block or a top level reporter in a stackClick.
|
||||
// TW: Don't mangle the stack when we just finished executing a hat block.
|
||||
// Hat block is always the top and first block of the script. There are no loops to find.
|
||||
if (lastOperation && (!blockCached._isHat || thread.stackClick)) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, lastOperation) => {
|
||||
if (thread.status === Thread.STATUS_RUNNING) {
|
||||
// Primitive returned a promise; automatically yield thread.
|
||||
thread.status = Thread.STATUS_PROMISE_WAIT;
|
||||
}
|
||||
// Promise handlers
|
||||
primitiveReportedValue.then(resolvedValue => {
|
||||
handlePromiseResolution(resolvedValue, sequencer, thread, blockCached, lastOperation);
|
||||
}, rejectionReason => {
|
||||
// Promise rejected: the primitive had some error.
|
||||
log.warn('Primitive rejected promise: ', rejectionReason);
|
||||
handlePromiseResolution(`${rejectionReason}`, sequencer, thread, blockCached, lastOperation);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A execute.js internal representation of a block to reduce the time spent in
|
||||
* execute as the same blocks are called the most.
|
||||
*
|
||||
* With the help of the Blocks class create a mutable copy of block
|
||||
* information. The members of BlockCached derived values of block information
|
||||
* that does not need to be reevaluated until a change in Blocks. Since Blocks
|
||||
* handles where the cache instance is stored, it drops all cache versions of a
|
||||
* block when any change happens to it. This way we can quickly execute blocks
|
||||
* and keep perform the right action according to the current block information
|
||||
* in the editor.
|
||||
*
|
||||
* @param {Blocks} blockContainer the related Blocks instance
|
||||
* @param {object} cached default set of cached values
|
||||
*/
|
||||
class BlockCached {
|
||||
constructor (blockContainer, cached) {
|
||||
/**
|
||||
* Block id in its parent set of blocks.
|
||||
* @type {string}
|
||||
*/
|
||||
this.id = cached.id;
|
||||
|
||||
/**
|
||||
* Block operation code for this block.
|
||||
* @type {string}
|
||||
*/
|
||||
this.opcode = cached.opcode;
|
||||
|
||||
/**
|
||||
* Original block object containing argument values for static fields.
|
||||
* @type {object}
|
||||
*/
|
||||
this.fields = cached.fields;
|
||||
|
||||
/**
|
||||
* Original block object containing argument values for executable inputs.
|
||||
* @type {object}
|
||||
*/
|
||||
this.inputs = cached.inputs;
|
||||
|
||||
/**
|
||||
* Procedure mutation.
|
||||
* @type {?object}
|
||||
*/
|
||||
this.mutation = cached.mutation;
|
||||
|
||||
/**
|
||||
* The profiler the block is configured with.
|
||||
* @type {?Profiler}
|
||||
*/
|
||||
this._profiler = null;
|
||||
|
||||
/**
|
||||
* Profiler information frame.
|
||||
* @type {?ProfilerFrame}
|
||||
*/
|
||||
this._profilerFrame = null;
|
||||
|
||||
/**
|
||||
* Is the opcode a hat (event responder) block.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._isHat = false;
|
||||
|
||||
/**
|
||||
* The block opcode's implementation function.
|
||||
* @type {?function}
|
||||
*/
|
||||
this._blockFunction = null;
|
||||
|
||||
/**
|
||||
* Is the block function defined for this opcode?
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._definedBlockFunction = false;
|
||||
|
||||
/**
|
||||
* Is this block a block with no function but a static value to return.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._isShadowBlock = false;
|
||||
|
||||
/**
|
||||
* The static value of this block if it is a shadow block.
|
||||
* @type {?any}
|
||||
*/
|
||||
this._shadowValue = null;
|
||||
|
||||
/**
|
||||
* A copy of the block's fields that may be modified.
|
||||
* @type {object}
|
||||
*/
|
||||
this._fields = Object.assign({}, this.fields);
|
||||
|
||||
/**
|
||||
* A copy of the block's inputs that may be modified.
|
||||
* @type {object}
|
||||
*/
|
||||
this._inputs = Object.assign({}, this.inputs);
|
||||
|
||||
/**
|
||||
* An arguments object for block implementations. All executions of this
|
||||
* specific block will use this objecct.
|
||||
* @type {object}
|
||||
*/
|
||||
this._argValues = {
|
||||
mutation: this.mutation
|
||||
};
|
||||
|
||||
/**
|
||||
* The inputs key the parent refers to this BlockCached by.
|
||||
* @type {string}
|
||||
*/
|
||||
this._parentKey = null;
|
||||
|
||||
/**
|
||||
* The target object where the parent wants the resulting value stored
|
||||
* with _parentKey as the key.
|
||||
* @type {object}
|
||||
*/
|
||||
this._parentValues = null;
|
||||
|
||||
/**
|
||||
* A sequence of non-shadow operations that can must be performed. This
|
||||
* list recreates the order this block and its children are executed.
|
||||
* Since the order is always the same we can safely store that order
|
||||
* and iterate over the operations instead of dynamically walking the
|
||||
* tree every time.
|
||||
* @type {Array<BlockCached>}
|
||||
*/
|
||||
this._ops = [];
|
||||
|
||||
const {runtime} = blockUtility.sequencer;
|
||||
|
||||
const {opcode, fields, inputs} = this;
|
||||
|
||||
// Assign opcode isHat and blockFunction data to avoid dynamic lookups.
|
||||
this._isHat = runtime.getIsHat(opcode);
|
||||
this._blockFunction = runtime.getOpcodeFunction(opcode);
|
||||
this._definedBlockFunction = typeof this._blockFunction !== 'undefined';
|
||||
|
||||
const flowing = runtime._flowing[opcode];
|
||||
this._isConditional = !!(flowing && flowing.conditional);
|
||||
this._isLoop = !!(flowing && flowing.loop);
|
||||
|
||||
// Store the current shadow value if there is a shadow value.
|
||||
const fieldKeys = Object.keys(fields);
|
||||
this._isShadowBlock = (
|
||||
!this._definedBlockFunction &&
|
||||
fieldKeys.length === 1 &&
|
||||
Object.keys(inputs).length === 0
|
||||
);
|
||||
this._shadowValue = this._isShadowBlock && fields[fieldKeys[0]].value;
|
||||
|
||||
// Store the static fields onto _argValues.
|
||||
for (const fieldName in fields) {
|
||||
if (
|
||||
fieldName === 'VARIABLE' ||
|
||||
fieldName === 'LIST' ||
|
||||
fieldName === 'BROADCAST_OPTION'
|
||||
) {
|
||||
this._argValues[fieldName] = {
|
||||
id: fields[fieldName].id,
|
||||
name: fields[fieldName].value
|
||||
};
|
||||
} else {
|
||||
this._argValues[fieldName] = fields[fieldName].value;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove custom_block. It is not part of block execution.
|
||||
delete this._inputs.custom_block;
|
||||
|
||||
if ('BROADCAST_INPUT' in this._inputs) {
|
||||
// BROADCAST_INPUT is called BROADCAST_OPTION in the args and is an
|
||||
// object with an unchanging shape.
|
||||
this._argValues.BROADCAST_OPTION = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
// We can go ahead and compute BROADCAST_INPUT if it is a shadow
|
||||
// value.
|
||||
const broadcastInput = this._inputs.BROADCAST_INPUT;
|
||||
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;
|
||||
this._argValues.BROADCAST_OPTION.id = broadcastField.id;
|
||||
this._argValues.BROADCAST_OPTION.name = broadcastField.value;
|
||||
|
||||
// Evaluating BROADCAST_INPUT here we do not need to do so
|
||||
// later.
|
||||
delete this._inputs.BROADCAST_INPUT;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache all input children blocks in the operation lists. The
|
||||
// operations can later be run in the order they appear in correctly
|
||||
// executing the operations quickly in a flat loop instead of needing to
|
||||
// recursivly iterate them.
|
||||
for (const inputName in this._inputs) {
|
||||
const input = this._inputs[inputName];
|
||||
if (input.block) {
|
||||
const inputCached = BlocksExecuteCache.getCached(blockContainer, input.block, BlockCached);
|
||||
|
||||
if (inputCached._isHat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this._ops.push(...inputCached._ops);
|
||||
inputCached._parentKey = inputName;
|
||||
inputCached._parentValues = this._argValues;
|
||||
|
||||
// Shadow values are static and do not change, go ahead and
|
||||
// store their value on args.
|
||||
if (inputCached._isShadowBlock) {
|
||||
this._argValues[inputName] = inputCached._shadowValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The final operation is this block itself. At the top most block is a
|
||||
// command block or a block that is being run as a monitor.
|
||||
if (this._definedBlockFunction) {
|
||||
this._ops.push(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a BlockCached instance so its command/hat
|
||||
* block and reporters can be profiled during execution.
|
||||
* @param {Profiler} profiler - The profiler that is currently enabled.
|
||||
* @param {BlockCached} blockCached - The blockCached instance to profile.
|
||||
*/
|
||||
const _prepareBlockProfiling = function (profiler, blockCached) {
|
||||
blockCached._profiler = profiler;
|
||||
|
||||
if (blockFunctionProfilerId === -1) {
|
||||
blockFunctionProfilerId = profiler.idByName(blockFunctionProfilerFrame);
|
||||
}
|
||||
|
||||
const ops = blockCached._ops;
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
ops[i]._profilerFrame = profiler.frame(blockFunctionProfilerId, ops[i].opcode);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a block.
|
||||
* @param {!Sequencer} sequencer Which sequencer is executing.
|
||||
* @param {!Thread} thread Thread which to read and execute.
|
||||
*/
|
||||
const execute = function (sequencer, thread) {
|
||||
const runtime = sequencer.runtime;
|
||||
|
||||
// store sequencer and thread so block functions can access them through
|
||||
// convenience methods.
|
||||
blockUtility.sequencer = sequencer;
|
||||
blockUtility.thread = thread;
|
||||
|
||||
// Current block to execute is the one on the top of the stack.
|
||||
const currentBlockId = thread.peekStack();
|
||||
const currentStackFrame = thread.peekStackFrame();
|
||||
|
||||
let blockContainer = thread.blockContainer;
|
||||
let blockCached = BlocksExecuteCache.getCached(blockContainer, currentBlockId, BlockCached);
|
||||
if (blockCached === null) {
|
||||
blockContainer = runtime.flyoutBlocks;
|
||||
blockCached = BlocksExecuteCache.getCached(blockContainer, currentBlockId, BlockCached);
|
||||
// Stop if block or target no longer exists.
|
||||
if (blockCached === null) {
|
||||
// No block found: stop the thread; script no longer exists.
|
||||
sequencer.retireThread(thread);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ops = blockCached._ops;
|
||||
const length = ops.length;
|
||||
let i = 0;
|
||||
|
||||
if (currentStackFrame.reported !== null) {
|
||||
const reported = currentStackFrame.reported;
|
||||
// Reinstate all the previous values.
|
||||
for (; i < reported.length; i++) {
|
||||
const {opCached: oldOpCached, inputValue} = reported[i];
|
||||
|
||||
const opCached = ops.find(op => op.id === oldOpCached);
|
||||
|
||||
if (opCached) {
|
||||
const inputName = opCached._parentKey;
|
||||
const argValues = opCached._parentValues;
|
||||
|
||||
if (inputName === 'BROADCAST_INPUT') {
|
||||
// Something is plugged into the broadcast input.
|
||||
// Cast it to a string. We don't need an id here.
|
||||
argValues.BROADCAST_OPTION.id = null;
|
||||
argValues.BROADCAST_OPTION.name = cast.toString(inputValue);
|
||||
} else {
|
||||
argValues[inputName] = inputValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the last reported block that is still in the set of operations.
|
||||
// This way if the last operation was removed, we'll find the next
|
||||
// candidate. If an earlier block that was performed was removed then
|
||||
// we'll find the index where the last operation is now.
|
||||
if (reported.length > 0) {
|
||||
const lastExisting = reported.reverse().find(report => ops.find(op => op.id === report.opCached));
|
||||
if (lastExisting) {
|
||||
i = ops.findIndex(opCached => opCached.id === lastExisting.opCached) + 1;
|
||||
} else {
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The reporting block must exist and must be the next one in the sequence of operations.
|
||||
if (thread.justReported !== null && ops[i] && ops[i].id === currentStackFrame.reporting) {
|
||||
const opCached = ops[i];
|
||||
const inputValue = thread.justReported;
|
||||
|
||||
thread.justReported = null;
|
||||
|
||||
const inputName = opCached._parentKey;
|
||||
const argValues = opCached._parentValues;
|
||||
|
||||
if (inputName === 'BROADCAST_INPUT') {
|
||||
// Something is plugged into the broadcast input.
|
||||
// Cast it to a string. We don't need an id here.
|
||||
argValues.BROADCAST_OPTION.id = null;
|
||||
argValues.BROADCAST_OPTION.name = cast.toString(inputValue);
|
||||
} else {
|
||||
argValues[inputName] = inputValue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
currentStackFrame.reporting = null;
|
||||
currentStackFrame.reported = null;
|
||||
currentStackFrame.waitingReporter = false;
|
||||
}
|
||||
|
||||
const start = i;
|
||||
|
||||
for (; i < length; i++) {
|
||||
const lastOperation = i === length - 1;
|
||||
const opCached = ops[i];
|
||||
currentStackFrame.op = opCached;
|
||||
|
||||
const blockFunction = opCached._blockFunction;
|
||||
|
||||
// Update values for arguments (inputs).
|
||||
const argValues = opCached._argValues;
|
||||
|
||||
// Fields are set during opCached initialization.
|
||||
|
||||
// Blocks should glow when a script is starting,
|
||||
// not after it has finished (see #1404).
|
||||
// Only blocks in blockContainers that don't forceNoGlow
|
||||
// should request a glow.
|
||||
if (!blockContainer.forceNoGlow) {
|
||||
thread.requestScriptGlowInFrame = true;
|
||||
}
|
||||
|
||||
// Inputs are set during previous steps in the loop.
|
||||
|
||||
const primitiveReportedValue = blockFunction(argValues, blockUtility);
|
||||
|
||||
const primitiveIsPromise = isPromise(primitiveReportedValue);
|
||||
if (primitiveIsPromise || currentStackFrame.waitingReporter) {
|
||||
if (primitiveIsPromise) {
|
||||
handlePromise(primitiveReportedValue, sequencer, thread, opCached, lastOperation);
|
||||
}
|
||||
|
||||
// Store the already reported values. They will be thawed into the
|
||||
// future versions of the same operations by block id. The reporting
|
||||
// operation if it is promise waiting will set its parent value at
|
||||
// that time.
|
||||
thread.justReported = null;
|
||||
currentStackFrame.reporting = ops[i].id;
|
||||
currentStackFrame.reported = ops.slice(0, i).map(reportedCached => {
|
||||
const inputName = reportedCached._parentKey;
|
||||
const reportedValues = reportedCached._parentValues;
|
||||
|
||||
if (inputName === 'BROADCAST_INPUT') {
|
||||
return {
|
||||
opCached: reportedCached.id,
|
||||
inputValue: reportedValues[inputName].BROADCAST_OPTION.name
|
||||
};
|
||||
}
|
||||
return {
|
||||
opCached: reportedCached.id,
|
||||
inputValue: reportedValues[inputName]
|
||||
};
|
||||
});
|
||||
|
||||
// We are waiting to be resumed later. Stop running this set of operations
|
||||
// and continue them later after thawing the reported values.
|
||||
break;
|
||||
} else if (thread.status === Thread.STATUS_RUNNING) {
|
||||
if (lastOperation) {
|
||||
handleReport(primitiveReportedValue, sequencer, thread, opCached, lastOperation);
|
||||
} else {
|
||||
// By definition a block that is not last in the list has a
|
||||
// parent.
|
||||
const inputName = opCached._parentKey;
|
||||
const parentValues = opCached._parentValues;
|
||||
|
||||
if (inputName === 'BROADCAST_INPUT') {
|
||||
// Something is plugged into the broadcast input.
|
||||
// Cast it to a string. We don't need an id here.
|
||||
parentValues.BROADCAST_OPTION.id = null;
|
||||
parentValues.BROADCAST_OPTION.name = cast.toString(primitiveReportedValue);
|
||||
} else {
|
||||
parentValues[inputName] = primitiveReportedValue;
|
||||
}
|
||||
}
|
||||
} else if (thread.status === Thread.STATUS_DONE) {
|
||||
// Nothing else to execute.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (runtime.profiler !== null) {
|
||||
if (blockCached._profiler !== runtime.profiler) {
|
||||
_prepareBlockProfiling(runtime.profiler, blockCached);
|
||||
}
|
||||
// Determine the index that is after the last executed block. `i` is
|
||||
// currently the block that was just executed. `i + 1` will be the block
|
||||
// after that. `length` with the min call makes sure we don't try to
|
||||
// reference an operation outside of the set of operations.
|
||||
const end = Math.min(i + 1, length);
|
||||
for (let p = start; p < end; p++) {
|
||||
ops[p]._profilerFrame.count += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = execute;
|
||||
23
scratch-vm/src/engine/monitor-record.js
Normal file
23
scratch-vm/src/engine/monitor-record.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const {Record} = require('immutable');
|
||||
|
||||
const MonitorRecord = Record({
|
||||
id: null, // Block Id
|
||||
/** 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,
|
||||
mode: 'default',
|
||||
sliderMin: 0,
|
||||
sliderMax: 100,
|
||||
isDiscrete: true,
|
||||
x: null, // (x: null, y: null) Indicates that the monitor should be auto-positioned
|
||||
y: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
visible: true
|
||||
});
|
||||
|
||||
module.exports = MonitorRecord;
|
||||
48
scratch-vm/src/engine/mutation-adapter.js
Normal file
48
scratch-vm/src/engine/mutation-adapter.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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]);
|
||||
// Note: the capitalization of block info in the following lines is important.
|
||||
// The lowercase is read in from xml which normalizes case. The VM uses camel case everywhere else.
|
||||
if (prop === 'blockinfo') {
|
||||
obj.blockInfo = JSON.parse(obj.blockinfo);
|
||||
delete obj.blockinfo;
|
||||
}
|
||||
}
|
||||
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.
|
||||
*/
|
||||
const mutationAdpater = function (mutation) {
|
||||
let mutationParsed;
|
||||
// Check if the mutation is already parsed; if not, parse it.
|
||||
if (typeof mutation === 'object') {
|
||||
mutationParsed = mutation;
|
||||
} else {
|
||||
mutationParsed = html.parseDOM(mutation)[0];
|
||||
}
|
||||
return mutatorTagToObject(mutationParsed);
|
||||
};
|
||||
|
||||
module.exports = mutationAdpater;
|
||||
390
scratch-vm/src/engine/profiler.js
Normal file
390
scratch-vm/src/engine/profiler.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @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.<string, number>}
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* A summarized count of the number of calls to this frame.
|
||||
* @type {number}
|
||||
*/
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* An array of frames incremented on demand instead as part of start
|
||||
* and stop.
|
||||
* @type {Array.<ProfilerFrame>}
|
||||
*/
|
||||
this.increments = [];
|
||||
|
||||
/**
|
||||
* An array of profiler frames separated by counter argument. Generally
|
||||
* for Scratch these frames are separated by block function opcode.
|
||||
* This tracks each time an opcode is called.
|
||||
* @type {Array.<ProfilerFrame>}
|
||||
*/
|
||||
this.counters = [];
|
||||
|
||||
/**
|
||||
* A frame with no id or argument.
|
||||
* @type {ProfilerFrame}
|
||||
*/
|
||||
this.nullFrame = new ProfilerFrame(-1);
|
||||
|
||||
/**
|
||||
* A cache of ProfilerFrames to reuse when reporting the recorded
|
||||
* frames in records.
|
||||
* @type {Array.<ProfilerFrame>}
|
||||
*/
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times this symbol is called.
|
||||
* @param {number} id The id returned by idByName for a name symbol.
|
||||
*/
|
||||
increment (id) {
|
||||
if (!this.increments[id]) {
|
||||
this.increments[id] = new ProfilerFrame(-1);
|
||||
this.increments[id].id = id;
|
||||
}
|
||||
this.increments[id].count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a ProfilerFrame-like object whose counter can be
|
||||
* incremented outside of the Profiler.
|
||||
* @param {number} id The id returned by idByName for a name symbol.
|
||||
* @param {*} arg The argument for a frame that identifies it in addition
|
||||
* to the id.
|
||||
* @return {{count: number}} A ProfilerFrame-like whose count should be
|
||||
* incremented for each call.
|
||||
*/
|
||||
frame (id, arg) {
|
||||
for (let i = 0; i < this.counters.length; i++) {
|
||||
if (this.counters[i].id === id && this.counters[i].arg === arg) {
|
||||
return this.counters[i];
|
||||
}
|
||||
}
|
||||
|
||||
const newCounter = new ProfilerFrame(-1);
|
||||
newCounter.id = id;
|
||||
newCounter.arg = arg;
|
||||
this.counters.push(newCounter);
|
||||
return newCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 frame occured once.
|
||||
frame.count = 1;
|
||||
|
||||
this.onFrame(frame);
|
||||
|
||||
i += STOP_SIZE;
|
||||
} else {
|
||||
this.records.length = 0;
|
||||
throw new Error('Unable to decode Profiler records.');
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < this.increments.length; j++) {
|
||||
if (this.increments[j] && this.increments[j].count > 0) {
|
||||
this.onFrame(this.increments[j]);
|
||||
this.increments[j].count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (let k = 0; k < this.counters.length; k++) {
|
||||
if (this.counters[k].count > 0) {
|
||||
this.onFrame(this.counters[k]);
|
||||
this.counters[k].count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
3495
scratch-vm/src/engine/runtime.js
Normal file
3495
scratch-vm/src/engine/runtime.js
Normal file
File diff suppressed because it is too large
Load Diff
27
scratch-vm/src/engine/scratch-blocks-constants.js
Normal file
27
scratch-vm/src/engine/scratch-blocks-constants.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* These constants are copied from scratch-blocks/core/constants.js
|
||||
* @TODO find a way to require() these straight from scratch-blocks... maybe make a scratch-blocks/dist/constants.js?
|
||||
* @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
|
||||
};
|
||||
|
||||
module.exports = ScratchBlocksConstants;
|
||||
372
scratch-vm/src/engine/sequencer.js
Normal file
372
scratch-vm/src/engine/sequencer.js
Normal file
@@ -0,0 +1,372 @@
|
||||
const Timer = require('../util/timer');
|
||||
const Thread = require('./thread');
|
||||
const execute = require('./execute.js');
|
||||
const compilerExecute = require('../compiler/jsexecute');
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
let stepThreadProfilerId = -1;
|
||||
|
||||
/**
|
||||
* Profiler frame ID for stepThreadsInnerProfilerFrame.
|
||||
* @type {number}
|
||||
*/
|
||||
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;
|
||||
|
||||
this.activeThread = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.<!Thread>} List of inactive threads after stepping.
|
||||
*/
|
||||
stepThreads () {
|
||||
// Work time is 75% of the thread stepping interval.
|
||||
const WORK_TIME = 0.75 * this.runtime.currentStepTime;
|
||||
// For compatibility with Scatch 2, update the millisecond clock
|
||||
// on the Runtime once per step (see Interpreter.as in Scratch 2
|
||||
// for original use of `currentMSecs`)
|
||||
this.runtime.updateCurrentMSecs();
|
||||
// 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 = [];
|
||||
// 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;
|
||||
let stoppedThread = false;
|
||||
// Attempt to run each thread one time.
|
||||
const threads = this.runtime.threads;
|
||||
for (let i = 0; i < threads.length; i++) {
|
||||
const activeThread = this.activeThread = threads[i];
|
||||
// Check if the thread is done so it is not executed.
|
||||
if (activeThread.stack.length === 0 ||
|
||||
activeThread.status === Thread.STATUS_DONE) {
|
||||
// Finished with this thread.
|
||||
stoppedThread = true;
|
||||
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.
|
||||
if (this.runtime.profiler !== null) {
|
||||
if (stepThreadProfilerId === -1) {
|
||||
stepThreadProfilerId = this.runtime.profiler.idByName(stepThreadProfilerFrame);
|
||||
}
|
||||
|
||||
// Increment the number of times stepThread is called.
|
||||
this.runtime.profiler.increment(stepThreadProfilerId);
|
||||
}
|
||||
this.stepThread(activeThread);
|
||||
activeThread.warpTimer = null;
|
||||
}
|
||||
if (activeThread.status === Thread.STATUS_RUNNING) {
|
||||
numActiveThreads++;
|
||||
}
|
||||
// Check if the thread completed while it just stepped to make
|
||||
// sure we remove it before the next iteration of all threads.
|
||||
if (activeThread.stack.length === 0 ||
|
||||
activeThread.status === Thread.STATUS_DONE) {
|
||||
// Finished with this thread.
|
||||
stoppedThread = true;
|
||||
}
|
||||
}
|
||||
// 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`.
|
||||
if (stoppedThread) {
|
||||
let nextActiveThread = 0;
|
||||
for (let i = 0; i < this.runtime.threads.length; i++) {
|
||||
const thread = this.runtime.threads[i];
|
||||
if (thread.stack.length !== 0 &&
|
||||
thread.status !== Thread.STATUS_DONE) {
|
||||
this.runtime.threads[nextActiveThread] = thread;
|
||||
nextActiveThread++;
|
||||
} else {
|
||||
this.runtime.threadMap.delete(thread.getId());
|
||||
doneThreads.push(thread);
|
||||
}
|
||||
}
|
||||
this.runtime.threads.length = nextActiveThread;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeThread = null;
|
||||
|
||||
return doneThreads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the requested thread for as long as necessary.
|
||||
* @param {!Thread} thread Thread object to step.
|
||||
*/
|
||||
stepThread (thread) {
|
||||
if (thread.isCompiled) {
|
||||
compilerExecute(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentBlockId = thread.peekStack();
|
||||
if (!currentBlockId) {
|
||||
// A "null block" - empty branch.
|
||||
thread.popStack();
|
||||
|
||||
// Did the null follow a hat block?
|
||||
if (thread.stack.length === 0) {
|
||||
thread.status = Thread.STATUS_DONE;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Save the current block ID to notice if we did control flow.
|
||||
while ((currentBlockId = 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.
|
||||
if (this.runtime.profiler !== null) {
|
||||
if (executeProfilerId === -1) {
|
||||
executeProfilerId = this.runtime.profiler.idByName(executeProfilerFrame);
|
||||
}
|
||||
|
||||
// Increment the number of times execute is called.
|
||||
this.runtime.profiler.increment(executeProfilerId);
|
||||
}
|
||||
if (thread.target === null) {
|
||||
this.retireThread(thread);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
} else if (thread.status === Thread.STATUS_YIELD_TICK) {
|
||||
// stepThreads will reset the thread to Thread.STATUS_RUNNING
|
||||
return;
|
||||
} else if (thread.status === Thread.STATUS_DONE) {
|
||||
// Nothing more to execute.
|
||||
return;
|
||||
}
|
||||
// If no control flow has happened, switch to next block.
|
||||
if (thread.peekStack() === currentBlockId && !thread.peekStackFrame().waitingReporter) {
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
continue;
|
||||
}
|
||||
// Get next block of existing block on the stack.
|
||||
thread.goToNextBlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// 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 {
|
||||
// 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.
|
||||
*/
|
||||
retireThread (thread) {
|
||||
thread.stack = [];
|
||||
thread.stackFrame = [];
|
||||
thread.requestScriptGlowInFrame = false;
|
||||
thread.status = Thread.STATUS_DONE;
|
||||
if (thread.isCompiled) {
|
||||
thread.procedures = null;
|
||||
thread.generator = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Sequencer;
|
||||
29
scratch-vm/src/engine/stage-layering.js
Normal file
29
scratch-vm/src/engine/stage-layering.js
Normal file
@@ -0,0 +1,29 @@
|
||||
class StageLayering {
|
||||
static get BACKGROUND_LAYER () {
|
||||
return 'background';
|
||||
}
|
||||
|
||||
static get VIDEO_LAYER () {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
static get PEN_LAYER () {
|
||||
return 'pen';
|
||||
}
|
||||
|
||||
static get SPRITE_LAYER () {
|
||||
return 'sprite';
|
||||
}
|
||||
|
||||
// Order of layer groups relative to each other,
|
||||
static get LAYER_GROUPS () {
|
||||
return [
|
||||
StageLayering.BACKGROUND_LAYER,
|
||||
StageLayering.VIDEO_LAYER,
|
||||
StageLayering.PEN_LAYER,
|
||||
StageLayering.SPRITE_LAYER
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StageLayering;
|
||||
805
scratch-vm/src/engine/target.js
Normal file
805
scratch-vm/src/engine/target.js
Normal file
@@ -0,0 +1,805 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const Blocks = require('./blocks');
|
||||
const Variable = require('../engine/variable');
|
||||
const Comment = require('../engine/comment');
|
||||
const uid = require('../util/uid');
|
||||
const {Map} = require('immutable');
|
||||
const log = require('../util/log');
|
||||
const StringUtil = require('../util/string-util');
|
||||
const VariableUtil = require('../util/variable-util');
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* A Target is an abstract "code-running" object for the Scratch VM.
|
||||
* Examples include sprites/clones or potentially physical-world devices.
|
||||
*/
|
||||
|
||||
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(runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 id.
|
||||
* @type {Object.<string,*>}
|
||||
*/
|
||||
this.variables = {};
|
||||
/**
|
||||
* Dictionary of comments for this target.
|
||||
* Key is the comment id.
|
||||
* @type {Object.<string,*>}
|
||||
*/
|
||||
this.comments = {};
|
||||
/**
|
||||
* 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.<string,*>}
|
||||
*/
|
||||
this._customState = {};
|
||||
|
||||
/**
|
||||
* Currently known values for edge-activated hats.
|
||||
* Keys are block ID for the hat; values are the currently known values.
|
||||
* @type {Object.<string, *>}
|
||||
*/
|
||||
this._edgeActivatedHatValues = {};
|
||||
|
||||
/**
|
||||
* Maps extension ID to a JSON-serializable value.
|
||||
* @type {Object.<string, object>}
|
||||
*/
|
||||
this.extensionStorage = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the project receives a "green flag."
|
||||
* @abstract
|
||||
*/
|
||||
onGreenFlag () {}
|
||||
|
||||
/**
|
||||
* Return a human-readable name for this target.
|
||||
* Target implementations should override this.
|
||||
* @abstract
|
||||
* @returns {string} Human-readable name for the target.
|
||||
*/
|
||||
getName () {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
hasEdgeActivatedValue (blockId) {
|
||||
return Object.prototype.hasOwnProperty.call(this._edgeActivatedHatValues, blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all edge-activaed hat values.
|
||||
*/
|
||||
clearEdgeActivatedValues () {
|
||||
this._edgeActivatedHatValues = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a variable object, first by id, and then by name if the id is not found.
|
||||
* Create a new variable if both lookups fail.
|
||||
* @param {string} id Id of the variable.
|
||||
* @param {string} name Name of the variable.
|
||||
* @return {!Variable} Variable object.
|
||||
*/
|
||||
lookupOrCreateVariable (id, name) {
|
||||
let variable = this.lookupVariableById(id);
|
||||
if (variable) return variable;
|
||||
|
||||
variable = this.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 (Object.prototype.hasOwnProperty.call(this.variables, 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 && Object.prototype.hasOwnProperty.call(stage.variables, id)) {
|
||||
return stage.variables[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a variable object by its name and variable type.
|
||||
* Search begins with local variables; then global variables if a local one
|
||||
* was not found.
|
||||
* @param {string} name Name of the variable.
|
||||
* @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE.
|
||||
* @param {?bool} skipStage Optional flag to skip checking the stage
|
||||
* @return {?Variable} Variable object if found, or null if not.
|
||||
*/
|
||||
lookupVariableByNameAndType (name, type, skipStage) {
|
||||
if (typeof name !== 'string') return;
|
||||
if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
|
||||
skipStage = skipStage || false;
|
||||
|
||||
for (const varId in this.variables) {
|
||||
const currVar = this.variables[varId];
|
||||
if (currVar.name === name && currVar.type === type) {
|
||||
return currVar;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipStage && this.runtime && !this.isStage) {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
if (stage) {
|
||||
for (const varId in stage.variables) {
|
||||
const currVar = stage.variables[varId];
|
||||
if (currVar.name === name && currVar.type === type) {
|
||||
return currVar;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
let list = this.lookupVariableById(id);
|
||||
if (list) return list;
|
||||
|
||||
list = this.lookupVariableByNameAndType(name, Variable.LIST_TYPE);
|
||||
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'
|
||||
* @param {boolean} isCloud Whether the variable to create has the isCloud flag set.
|
||||
* Additional checks are made that the variable can be created as a cloud variable.
|
||||
*/
|
||||
createVariable (id, name, type, isCloud) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.variables, id)) {
|
||||
const newVariable = new Variable(id, name, type, false);
|
||||
if (isCloud && this.isStage && this.runtime.canAddCloudVariable()) {
|
||||
newVariable.isCloud = true;
|
||||
this.runtime.addCloudVariable();
|
||||
this.runtime.ioDevices.cloud.requestCreateVariable(newVariable);
|
||||
}
|
||||
this.variables[id] = newVariable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a comment with the given properties.
|
||||
* @param {string} id Id of the comment.
|
||||
* @param {string} blockId Optional id of the block the comment is attached
|
||||
* to if it is a block comment.
|
||||
* @param {string} text The text the comment contains.
|
||||
* @param {number} x The x coordinate of the comment on the workspace.
|
||||
* @param {number} y The y coordinate of the comment on the workspace.
|
||||
* @param {number} width The width of the comment when it is full size
|
||||
* @param {number} height The height of the comment when it is full size
|
||||
* @param {boolean} minimized Whether the comment is minimized.
|
||||
*/
|
||||
createComment (id, blockId, text, x, y, width, height, minimized) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.comments, id)) {
|
||||
const newComment = new Comment(id, text, x, y,
|
||||
width, height, minimized);
|
||||
if (blockId) {
|
||||
newComment.blockId = blockId;
|
||||
const blockWithComment = this.blocks.getBlock(blockId);
|
||||
if (blockWithComment) {
|
||||
blockWithComment.comment = id;
|
||||
} else {
|
||||
log.warn(`Could not find block with id ${blockId
|
||||
} associated with commentId: ${id}`);
|
||||
}
|
||||
}
|
||||
this.comments[id] = newComment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames the variable with the given id to newName.
|
||||
* @param {string} id Id of variable to rename.
|
||||
* @param {string} newName New name for the variable.
|
||||
*/
|
||||
renameVariable (id, newName) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
|
||||
const variable = this.variables[id];
|
||||
if (variable.id === id) {
|
||||
const oldName = variable.name;
|
||||
variable.name = newName;
|
||||
|
||||
if (this.runtime) {
|
||||
if (variable.isCloud && this.isStage) {
|
||||
this.runtime.ioDevices.cloud.requestRenameVariable(oldName, newName);
|
||||
}
|
||||
|
||||
if (variable.type === Variable.SCALAR_TYPE) {
|
||||
// sensing__of may be referencing to this variable.
|
||||
// Change the reference.
|
||||
let blockUpdated = false;
|
||||
this.runtime.targets.forEach(t => {
|
||||
blockUpdated = t.blocks.updateSensingOfReference(
|
||||
oldName,
|
||||
newName,
|
||||
this.isStage ? '_stage_' : this.getName()
|
||||
) || blockUpdated;
|
||||
});
|
||||
// Request workspace change only if sensing_of blocks were actually updated.
|
||||
if (blockUpdated) this.runtime.requestBlocksUpdate();
|
||||
}
|
||||
|
||||
const blocks = this.runtime.monitorBlocks;
|
||||
blocks.changeBlock({
|
||||
id: id,
|
||||
element: 'field',
|
||||
name: variable.type === Variable.LIST_TYPE ? 'LIST' : 'VARIABLE',
|
||||
value: id
|
||||
}, this.runtime);
|
||||
const monitorBlock = blocks.getBlock(variable.id);
|
||||
if (monitorBlock) {
|
||||
this.runtime.requestUpdateMonitor(Map({
|
||||
id: id,
|
||||
params: blocks._getBlockParams(monitorBlock)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the variable with the given id from the dictionary of variables.
|
||||
* @param {string} id Id of variable to delete.
|
||||
*/
|
||||
deleteVariable (id) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
|
||||
// Get info about the variable before deleting it
|
||||
const deletedVariableName = this.variables[id].name;
|
||||
const deletedVariableWasCloud = this.variables[id].isCloud;
|
||||
delete this.variables[id];
|
||||
if (this.runtime) {
|
||||
if (deletedVariableWasCloud && this.isStage) {
|
||||
this.runtime.ioDevices.cloud.requestDeleteVariable(deletedVariableName);
|
||||
this.runtime.removeCloudVariable();
|
||||
}
|
||||
this.runtime.monitorBlocks.deleteBlock(id);
|
||||
this.runtime.requestRemoveMonitor(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this target's monitors from the runtime state and remove the
|
||||
* target-specific monitored blocks (e.g. local variables, global variables for the stage, x-position).
|
||||
* NOTE: This does not delete any of the stage monitors like backdrop name.
|
||||
*/
|
||||
deleteMonitors () {
|
||||
this.runtime.requestRemoveMonitorByTargetId(this.id);
|
||||
let targetSpecificMonitorBlockIds;
|
||||
if (this.isStage) {
|
||||
// This only deletes global variables and not other stage monitors like backdrop number.
|
||||
targetSpecificMonitorBlockIds = Object.keys(this.variables);
|
||||
} else {
|
||||
targetSpecificMonitorBlockIds = Object.keys(this.runtime.monitorBlocks._blocks)
|
||||
.filter(key => this.runtime.monitorBlocks._blocks[key].targetId === this.id);
|
||||
}
|
||||
for (const blockId of targetSpecificMonitorBlockIds) {
|
||||
this.runtime.monitorBlocks.deleteBlock(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a clone of the variable with the given id from the dictionary of
|
||||
* this target's variables.
|
||||
* @param {string} id Id of variable to duplicate.
|
||||
* @param {boolean=} optKeepOriginalId Optional flag to keep the original variable ID
|
||||
* for the duplicate variable. This is necessary when cloning a sprite, for example.
|
||||
* @return {?Variable} The duplicated variable, or null if
|
||||
* the original variable was not found.
|
||||
*/
|
||||
duplicateVariable (id, optKeepOriginalId) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
|
||||
const originalVariable = this.variables[id];
|
||||
const newVariable = new Variable(
|
||||
optKeepOriginalId ? id : null, // conditionally keep original id or generate a new one
|
||||
originalVariable.name,
|
||||
originalVariable.type,
|
||||
originalVariable.isCloud
|
||||
);
|
||||
if (newVariable.type === Variable.LIST_TYPE) {
|
||||
newVariable.value = originalVariable.value.slice(0);
|
||||
} else {
|
||||
newVariable.value = originalVariable.value;
|
||||
}
|
||||
return newVariable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the dictionary of this target's variables as part of duplicating.
|
||||
* this target or making a clone.
|
||||
* @param {object=} optBlocks Optional block container for the target being duplicated.
|
||||
* If provided, new variables will be generated with new UIDs and any variable references
|
||||
* in this blocks container will be updated to refer to the corresponding new IDs.
|
||||
* @return {object} The duplicated dictionary of variables
|
||||
*/
|
||||
duplicateVariables (optBlocks) {
|
||||
let allVarRefs;
|
||||
if (optBlocks) {
|
||||
allVarRefs = optBlocks.getAllVariableAndListReferences();
|
||||
}
|
||||
return Object.keys(this.variables).reduce((accum, varId) => {
|
||||
const newVariable = this.duplicateVariable(varId, !optBlocks);
|
||||
accum[newVariable.id] = newVariable;
|
||||
if (optBlocks && allVarRefs) {
|
||||
const currVarRefs = allVarRefs[varId];
|
||||
if (currVarRefs) {
|
||||
this.mergeVariables(varId, newVariable.id, currVarRefs);
|
||||
}
|
||||
}
|
||||
return accum;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
if (this.runtime) {
|
||||
this.runtime.removeExecutable(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Variable Conflict Resolution Helpers
|
||||
|
||||
/**
|
||||
* Get the names of all the variables of the given type that are in scope for this target.
|
||||
* For targets that are not the stage, this includes any target-specific
|
||||
* variables as well as any stage variables unless the skipStage flag is true.
|
||||
* For the stage, this is all stage variables.
|
||||
* @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE
|
||||
* @param {?bool} skipStage Optional flag to skip the stage.
|
||||
* @return {Array<string>} A list of variable names
|
||||
*/
|
||||
getAllVariableNamesInScopeByType (type, skipStage) {
|
||||
if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
|
||||
skipStage = skipStage || false;
|
||||
const targetVariables = Object.values(this.variables)
|
||||
.filter(v => v.type === type)
|
||||
.map(variable => variable.name);
|
||||
if (skipStage || this.isStage || !this.runtime) {
|
||||
return targetVariables;
|
||||
}
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
const stageVariables = stage.getAllVariableNamesInScopeByType(type);
|
||||
return targetVariables.concat(stageVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge variable references with another variable.
|
||||
* @param {string} idToBeMerged ID of the variable whose references need to be updated
|
||||
* @param {string} idToMergeWith ID of the variable that the old references should be replaced with
|
||||
* @param {?Array<Object>} optReferencesToUpdate Optional context of the change.
|
||||
* Defaults to all the blocks in this target.
|
||||
* @param {?string} optNewName New variable name to merge with. The old
|
||||
* variable name in the references being updated should be replaced with this new name.
|
||||
* If this parameter is not provided or is '', no name change occurs.
|
||||
*/
|
||||
mergeVariables (idToBeMerged, idToMergeWith, optReferencesToUpdate, optNewName) {
|
||||
const referencesToChange = optReferencesToUpdate ||
|
||||
// TODO should there be a separate helper function that traverses the blocks
|
||||
// for all references for a given ID instead of doing the below..?
|
||||
this.blocks.getAllVariableAndListReferences()[idToBeMerged];
|
||||
|
||||
VariableUtil.updateVariableIdentifiers(referencesToChange, idToMergeWith, optNewName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a local variable (and given references for that variable) to the stage.
|
||||
* @param {string} varId The ID of the variable to share.
|
||||
* @param {Array<object>} varRefs The list of variable references being shared,
|
||||
* that reference the given variable ID. The names and IDs of these variable
|
||||
* references will be updated to refer to the new (or pre-existing) global variable.
|
||||
*/
|
||||
shareLocalVariableToStage (varId, varRefs) {
|
||||
if (!this.runtime) return;
|
||||
const variable = this.variables[varId];
|
||||
if (!variable) {
|
||||
log.warn(`Cannot share a local variable to the stage if it's not local.`);
|
||||
return;
|
||||
}
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
// If a local var is being shared with the stage,
|
||||
// sharing will make the variable global, resulting in a conflict
|
||||
// with the existing local variable. Preemptively Resolve this conflict
|
||||
// by renaming the new global variable.
|
||||
|
||||
// First check if we've already done the local to global transition for this
|
||||
// variable. If we have, merge it with the global variable we've already created.
|
||||
const varIdForStage = `StageVarFromLocal_${varId}`;
|
||||
let stageVar = stage.lookupVariableById(varIdForStage);
|
||||
// If a global var doesn't already exist, create a new one with a fresh name.
|
||||
// Use the ID we created above so that we can lookup this new variable in the
|
||||
// future if we decide to share this same variable again.
|
||||
if (!stageVar) {
|
||||
const varName = variable.name;
|
||||
const varType = variable.type;
|
||||
|
||||
const newStageName = `Stage: ${varName}`;
|
||||
stageVar = this.runtime.createNewGlobalVariable(newStageName, varIdForStage, varType);
|
||||
}
|
||||
// Update all variable references to use the new name and ID
|
||||
this.mergeVariables(varId, stageVar.id, varRefs, stageVar.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a local variable with a sprite, merging with one of the same name and
|
||||
* type if it already exists on the sprite, or create a new one.
|
||||
* @param {string} varId Id of the variable to share
|
||||
* @param {Target} sprite The sprite to share the variable with
|
||||
* @param {Array<object>} varRefs A list of all the variable references currently being shared.
|
||||
*/
|
||||
shareLocalVariableToSprite (varId, sprite, varRefs) {
|
||||
if (!this.runtime) return;
|
||||
if (this.isStage) return;
|
||||
const variable = this.variables[varId];
|
||||
if (!variable) {
|
||||
log.warn(`Tried to call 'shareLocalVariableToSprite' with a non-local variable.`);
|
||||
return;
|
||||
}
|
||||
const varName = variable.name;
|
||||
const varType = variable.type;
|
||||
// Check if the receiving sprite already has a variable of the same name and type
|
||||
// and use the existing variable, otherwise create a new one.
|
||||
const existingLocalVar = sprite.lookupVariableByNameAndType(varName, varType);
|
||||
let newVarId;
|
||||
if (existingLocalVar) {
|
||||
newVarId = existingLocalVar.id;
|
||||
} else {
|
||||
const newVar = new Variable(null, varName, varType);
|
||||
newVarId = newVar.id;
|
||||
sprite.variables[newVarId] = newVar;
|
||||
}
|
||||
|
||||
// Merge with the local variable on the new sprite.
|
||||
this.mergeVariables(varId, newVarId, varRefs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of variable referencing fields, shares those variables with
|
||||
* the target with the provided id, resolving any variable conflicts that arise
|
||||
* using the following rules:
|
||||
*
|
||||
* If this target is the stage, exit. There are no conflicts that arise
|
||||
* from sharing variables from the stage to another sprite. The variables
|
||||
* already exist globally, so no further action is needed.
|
||||
*
|
||||
* If a variable being referenced is a global variable, do nothing. The
|
||||
* global variable already exists so no further action is needed.
|
||||
*
|
||||
* If a variable being referenced is local, and
|
||||
* 1) The receiving target is a sprite:
|
||||
* create a new local variable or merge with an existing local variable
|
||||
* of the same name and type. Update all the referencing fields
|
||||
* for the original variable to reference the new variable.
|
||||
* 2) The receiving target is the stage:
|
||||
* Create a new global variable with a fresh name and update all the referencing
|
||||
* fields to reference the new variable.
|
||||
*
|
||||
* @param {Array<object>} blocks The blocks containing
|
||||
* potential conflicting references to variables.
|
||||
* @param {Target} receivingTarget The target receiving the variables
|
||||
*/
|
||||
resolveVariableSharingConflictsWithTarget (blocks, receivingTarget) {
|
||||
if (this.isStage) return;
|
||||
|
||||
// Get all the variable references in the given list of blocks
|
||||
const allVarListRefs = this.blocks.getAllVariableAndListReferences(blocks);
|
||||
|
||||
// For all the variables being referenced, check for which ones are local
|
||||
// to this target, and resolve conflicts based on whether the receiving target
|
||||
// is a sprite (with a conflicting local variable) or whether it is
|
||||
// the stage (which cannot have local variables)
|
||||
for (const varId in allVarListRefs) {
|
||||
const currVar = this.variables[varId];
|
||||
if (!currVar) continue; // The current variable is global, there shouldn't be any conflicts here, skip it.
|
||||
|
||||
// Get the list of references for the current variable id
|
||||
const currVarListRefs = allVarListRefs[varId];
|
||||
|
||||
if (receivingTarget.isStage) {
|
||||
this.shareLocalVariableToStage(varId, currVarListRefs);
|
||||
} else {
|
||||
this.shareLocalVariableToSprite(varId, receivingTarget, currVarListRefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes up variable references in this target avoiding conflicts with
|
||||
* pre-existing variables in the same scope.
|
||||
* This is used when uploading this target as a new sprite into an existing
|
||||
* project, where the new sprite may contain references
|
||||
* to variable names that already exist as global variables in the project
|
||||
* (and thus are in scope for variable references in the given sprite).
|
||||
*
|
||||
* If this target has a block that references an existing global variable and that
|
||||
* variable *does not* exist in this target (e.g. it was a global variable in the
|
||||
* project the sprite was originally exported from), merge the variables. This entails
|
||||
* fixing the variable references in this sprite to reference the id of the pre-existing global variable.
|
||||
*
|
||||
* If this target has a block that references an existing global variable and that
|
||||
* variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded),
|
||||
* then the local variable is renamed to distinguish itself from the pre-existing variable.
|
||||
* All blocks that reference the local variable will be updated to use the new name.
|
||||
*/
|
||||
// TODO (#1360) This function is too long, add some helpers for the different chunks and cases...
|
||||
fixUpVariableReferences () {
|
||||
if (!this.runtime) return; // There's no runtime context to conflict with
|
||||
if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded)
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
if (!stage || !stage.variables) return;
|
||||
|
||||
const renameConflictingLocalVar = (id, name, type) => {
|
||||
const conflict = stage.lookupVariableByNameAndType(name, type);
|
||||
if (conflict) {
|
||||
const newName = StringUtil.unusedName(
|
||||
`${this.getName()}: ${name}`,
|
||||
this.getAllVariableNamesInScopeByType(type));
|
||||
this.renameVariable(id, newName);
|
||||
return newName;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const allReferences = this.blocks.getAllVariableAndListReferences();
|
||||
const unreferencedLocalVarIds = [];
|
||||
if (Object.keys(this.variables).length > 0) {
|
||||
for (const localVarId in this.variables) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.variables, localVarId)) continue;
|
||||
if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId);
|
||||
}
|
||||
}
|
||||
const conflictIdsToReplace = Object.create(null);
|
||||
const conflictNamesToReplace = Object.create(null);
|
||||
|
||||
// Cache the list of all variable names by type so that we don't need to
|
||||
// re-calculate this in every iteration of the following loop.
|
||||
const varNamesByType = {};
|
||||
const allVarNames = type => {
|
||||
const namesOfType = varNamesByType[type];
|
||||
if (namesOfType) return namesOfType;
|
||||
varNamesByType[type] = this.runtime.getAllVarNamesOfType(type);
|
||||
return varNamesByType[type];
|
||||
};
|
||||
|
||||
for (const varId in allReferences) {
|
||||
// We don't care about which var ref we get, they should all have the same var info
|
||||
const varRef = allReferences[varId][0];
|
||||
const varName = varRef.referencingField.value;
|
||||
const varType = varRef.type;
|
||||
if (this.lookupVariableById(varId)) {
|
||||
// Found a variable with the id in either the target or the stage,
|
||||
// figure out which one.
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, varId)) {
|
||||
// If the target has the variable, then check whether the stage
|
||||
// has one with the same name and type. If it does, then rename
|
||||
// this target specific variable so that there is a distinction.
|
||||
const newVarName = renameConflictingLocalVar(varId, varName, varType);
|
||||
|
||||
if (newVarName) {
|
||||
// We are not calling this.blocks.updateBlocksAfterVarRename
|
||||
// here because it will search through all the blocks. We already
|
||||
// have access to all the references for this var id.
|
||||
allReferences[varId].map(ref => {
|
||||
ref.referencingField.value = newVarName;
|
||||
return ref;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We didn't find the referenced variable id anywhere,
|
||||
// Treat it as a reference to a global variable (from the original
|
||||
// project this sprite was exported from).
|
||||
// Check for whether a global variable of the same name and type exists,
|
||||
// and if so, track it to merge with the existing global in a second pass of the blocks.
|
||||
const existingVar = stage.lookupVariableByNameAndType(varName, varType);
|
||||
if (existingVar) {
|
||||
if (!conflictIdsToReplace[varId]) {
|
||||
conflictIdsToReplace[varId] = existingVar.id;
|
||||
}
|
||||
} else {
|
||||
// A global variable with the same name did not already exist,
|
||||
// create a new one such that it does not conflict with any
|
||||
// names of local variables of the same type.
|
||||
const allNames = allVarNames(varType);
|
||||
const freshName = StringUtil.unusedName(varName, allNames);
|
||||
stage.createVariable(varId, freshName, varType);
|
||||
if (!conflictNamesToReplace[varId]) {
|
||||
conflictNamesToReplace[varId] = freshName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rename any local variables that were missed above because they aren't
|
||||
// referenced by any blocks
|
||||
for (const id in unreferencedLocalVarIds) {
|
||||
const varId = unreferencedLocalVarIds[id];
|
||||
const name = this.variables[varId].name;
|
||||
const type = this.variables[varId].type;
|
||||
renameConflictingLocalVar(varId, name, type);
|
||||
}
|
||||
// Handle global var conflicts with existing global vars (e.g. a sprite is uploaded, and has
|
||||
// blocks referencing some variable that the sprite does not own, and this
|
||||
// variable conflicts with a global var)
|
||||
// In this case, we want to merge the new variable referenes with the
|
||||
// existing global variable
|
||||
for (const conflictId in conflictIdsToReplace) {
|
||||
const existingId = conflictIdsToReplace[conflictId];
|
||||
const referencesToUpdate = allReferences[conflictId];
|
||||
this.mergeVariables(conflictId, existingId, referencesToUpdate);
|
||||
}
|
||||
|
||||
// Handle global var conflicts existing local vars (e.g a sprite is uploaded,
|
||||
// and has blocks referencing some variable that the sprite does not own, and this
|
||||
// variable conflcits with another sprite's local var).
|
||||
// In this case, we want to go through the variable references and update
|
||||
// the name of the variable in that reference.
|
||||
for (const conflictId in conflictNamesToReplace) {
|
||||
const newName = conflictNamesToReplace[conflictId];
|
||||
const referencesToUpdate = allReferences[conflictId];
|
||||
referencesToUpdate.map(ref => {
|
||||
ref.referencingField.value = newName;
|
||||
return ref;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Target;
|
||||
530
scratch-vm/src/engine/thread.js
Normal file
530
scratch-vm/src/engine/thread.js
Normal file
@@ -0,0 +1,530 @@
|
||||
const log = require('../util/log');
|
||||
|
||||
/**
|
||||
* Recycle bin for empty stackFrame objects
|
||||
* @type Array<_StackFrame>
|
||||
*/
|
||||
const _stackFrameFreeList = [];
|
||||
|
||||
/**
|
||||
* A frame used for each level of the stack. A general purpose
|
||||
* place to store a bunch of execution context and parameters
|
||||
* @param {boolean} warpMode Whether this level of the stack is warping
|
||||
* @constructor
|
||||
* @private
|
||||
*/
|
||||
class _StackFrame {
|
||||
constructor (warpMode) {
|
||||
/**
|
||||
* Whether this level of the stack is a loop.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.isLoop = false;
|
||||
|
||||
/**
|
||||
* Whether this level is in warp mode. Is set by some legacy blocks and
|
||||
* "turbo mode"
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.warpMode = warpMode;
|
||||
|
||||
/**
|
||||
* Reported value from just executed block.
|
||||
* @type {Any}
|
||||
*/
|
||||
this.justReported = null;
|
||||
|
||||
/**
|
||||
* The active block that is waiting on a promise.
|
||||
* @type {string}
|
||||
*/
|
||||
this.reporting = '';
|
||||
|
||||
/**
|
||||
* Persists reported inputs during async block.
|
||||
* @type {Object}
|
||||
*/
|
||||
this.reported = null;
|
||||
|
||||
/**
|
||||
* Name of waiting reporter.
|
||||
* @type {string}
|
||||
*/
|
||||
this.waitingReporter = null;
|
||||
|
||||
/**
|
||||
* Procedure parameters.
|
||||
* @type {Object}
|
||||
*/
|
||||
this.params = null;
|
||||
|
||||
/**
|
||||
* A context passed to block implementations.
|
||||
* @type {Object}
|
||||
*/
|
||||
this.executionContext = null;
|
||||
|
||||
/**
|
||||
* Internal block object being executed. This is *not* the same as the object found
|
||||
* in target.blocks.
|
||||
* @type {object}
|
||||
*/
|
||||
this.op = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all properties of the frame to pristine null and false states.
|
||||
* Used to recycle.
|
||||
* @return {_StackFrame} this
|
||||
*/
|
||||
reset () {
|
||||
|
||||
this.isLoop = false;
|
||||
this.warpMode = false;
|
||||
this.justReported = null;
|
||||
this.reported = null;
|
||||
this.waitingReporter = null;
|
||||
this.params = null;
|
||||
this.executionContext = null;
|
||||
this.op = null;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an active stack frame in the stack.
|
||||
* @param {?boolean} warpMode defaults to current warpMode
|
||||
* @returns {_StackFrame} this
|
||||
*/
|
||||
reuse (warpMode = this.warpMode) {
|
||||
this.reset();
|
||||
this.warpMode = Boolean(warpMode);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or recycle a stack frame object.
|
||||
* @param {boolean} warpMode Enable warpMode on this frame.
|
||||
* @returns {_StackFrame} The clean stack frame with correct warpMode setting.
|
||||
*/
|
||||
static create (warpMode) {
|
||||
const stackFrame = _stackFrameFreeList.pop();
|
||||
if (typeof stackFrame !== 'undefined') {
|
||||
stackFrame.warpMode = Boolean(warpMode);
|
||||
return stackFrame;
|
||||
}
|
||||
return new _StackFrame(warpMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a stack frame object into the recycle bin for reuse.
|
||||
* @param {_StackFrame} stackFrame The frame to reset and recycle.
|
||||
*/
|
||||
static release (stackFrame) {
|
||||
if (typeof stackFrame !== 'undefined') {
|
||||
_stackFrameFreeList.push(stackFrame.reset());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread is a running stack context and all the metadata needed.
|
||||
* @param {?string} firstBlock First block to execute in the thread.
|
||||
* @constructor
|
||||
*/
|
||||
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.<string>}
|
||||
*/
|
||||
this.stack = [];
|
||||
|
||||
/**
|
||||
* Stack frames for the thread. Store metadata for the executing blocks.
|
||||
* @type {Array.<_StackFrame>}
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* The Blocks this thread will execute.
|
||||
* @type {Blocks}
|
||||
*/
|
||||
this.blockContainer = 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;
|
||||
|
||||
this.justReported = null;
|
||||
|
||||
this.triedToCompile = false;
|
||||
|
||||
this.isCompiled = false;
|
||||
|
||||
// compiler data
|
||||
// these values only make sense if isCompiled == true
|
||||
this.timer = null;
|
||||
/**
|
||||
* The thread's generator.
|
||||
* @type {Generator}
|
||||
*/
|
||||
this.generator = null;
|
||||
/**
|
||||
* @type {Object.<string, import('../compiler/compile').CompiledScript>}
|
||||
*/
|
||||
this.procedures = null;
|
||||
this.executableHat = 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
|
||||
*/
|
||||
static get STATUS_RUNNING () {
|
||||
return 0; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Threads are in this state when a primitive is waiting on a promise;
|
||||
* execution is paused until the promise changes thread status.
|
||||
* @const
|
||||
*/
|
||||
static get STATUS_PROMISE_WAIT () {
|
||||
return 1; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread status for yield.
|
||||
* @const
|
||||
*/
|
||||
static get STATUS_YIELD () {
|
||||
return 2; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread status for a single-tick yield. This will be cleared when the
|
||||
* thread is resumed.
|
||||
* @const
|
||||
*/
|
||||
static get STATUS_YIELD_TICK () {
|
||||
return 3; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread status for a finished/done thread.
|
||||
* Thread is in this state when there are no more blocks to execute.
|
||||
* @const
|
||||
*/
|
||||
static get STATUS_DONE () {
|
||||
return 4; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Target} target The target running the thread.
|
||||
* @param {string} topBlock ID of the thread's top block.
|
||||
* @returns {string} A unique ID for this target and thread.
|
||||
*/
|
||||
static getIdFromTargetAndBlock (target, topBlock) {
|
||||
// & should never appear in any IDs, so we can use it as a separator
|
||||
return `${target.id}&${topBlock}`;
|
||||
}
|
||||
|
||||
getId () {
|
||||
return Thread.getIdFromTargetAndBlock(this.target, this.topBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push stack and update stack frames appropriately.
|
||||
* @param {string} blockId Block ID to push to stack.
|
||||
*/
|
||||
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) {
|
||||
const parent = this.stackFrames[this.stackFrames.length - 1];
|
||||
this.stackFrames.push(_StackFrame.create(typeof parent !== 'undefined' && parent.warpMode));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
reuseStackForNextBlock (blockId) {
|
||||
this.stack[this.stack.length - 1] = blockId;
|
||||
this.stackFrames[this.stackFrames.length - 1].reuse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop last block on the stack and its stack frame.
|
||||
* @return {string} Block ID popped from the stack.
|
||||
*/
|
||||
popStack () {
|
||||
_StackFrame.release(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);
|
||||
|
||||
// Reporter form of procedures_call
|
||||
if (this.peekStackFrame().waitingReporter) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Command form of procedures_call
|
||||
if (typeof block !== 'undefined' && block.opcode === 'procedures_call') {
|
||||
// By definition, if we get here, the procedure is done, so skip ahead so
|
||||
// the arguments won't be re-evaluated and then discarded as frozen state
|
||||
// about which arguments have been evaluated is lost.
|
||||
// This fixes https://github.com/TurboWarp/scratch-vm/issues/201
|
||||
this.goToNextBlock();
|
||||
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) {
|
||||
this.justReported = typeof value === 'undefined' ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize procedure parameters on this stack frame.
|
||||
*/
|
||||
initParams () {
|
||||
const stackFrame = this.peekStackFrame();
|
||||
if (stackFrame.params === null) {
|
||||
stackFrame.params = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 === null) {
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(frame.params, paramName)) {
|
||||
return frame.params[paramName];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAllparams () {
|
||||
const stackFrame = this.peekStackFrame();
|
||||
return stackFrame.params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.stackFrames.length - 1;
|
||||
for (let i = sp - 1; i >= 0; i--) {
|
||||
const block = this.target.blocks.getBlock(this.stackFrames[i].op.id);
|
||||
if (block.opcode === 'procedures_call' &&
|
||||
block.mutation.proccode === procedureCode) {
|
||||
return true;
|
||||
}
|
||||
if (--callCount < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to compile this thread.
|
||||
*/
|
||||
tryCompile () {
|
||||
if (!this.blockContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// importing the compiler here avoids circular dependency issues
|
||||
const compile = require('../compiler/compile');
|
||||
|
||||
this.triedToCompile = true;
|
||||
|
||||
// stackClick === true disables hat block generation
|
||||
// It would be great to cache these separately, but for now it's easiest to just disable them to avoid
|
||||
// cached versions of scripts breaking projects.
|
||||
const canCache = !this.stackClick;
|
||||
|
||||
const topBlock = this.topBlock;
|
||||
// Flyout blocks are stored in a special block container.
|
||||
const blocks = this.blockContainer.getBlock(topBlock) ? this.blockContainer : this.target.runtime.flyoutBlocks;
|
||||
const cachedResult = canCache && blocks.getCachedCompileResult(topBlock);
|
||||
// If there is a cached error, do not attempt to recompile.
|
||||
if (cachedResult && !cachedResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (cachedResult) {
|
||||
result = cachedResult.value;
|
||||
} else {
|
||||
try {
|
||||
result = compile(this);
|
||||
if (canCache) {
|
||||
blocks.cacheCompileResult(topBlock, result);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('cannot compile script', this.target.getName(), error);
|
||||
if (canCache) {
|
||||
blocks.cacheCompileError(topBlock, error);
|
||||
}
|
||||
this.target.runtime.emitCompileError(this.target, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.procedures = {};
|
||||
for (const procedureCode of Object.keys(result.procedures)) {
|
||||
this.procedures[procedureCode] = result.procedures[procedureCode](this);
|
||||
}
|
||||
|
||||
this.generator = result.startingFunction(this)();
|
||||
|
||||
this.executableHat = result.executableHat;
|
||||
|
||||
if (!this.blockContainer.forceNoGlow) {
|
||||
this.blockGlowInFrame = this.topBlock;
|
||||
this.requestScriptGlowInFrame = true;
|
||||
}
|
||||
|
||||
this.isCompiled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// for extensions
|
||||
Thread._StackFrame = _StackFrame;
|
||||
|
||||
module.exports = Thread;
|
||||
230
scratch-vm/src/engine/tw-font-manager.js
Normal file
230
scratch-vm/src/engine/tw-font-manager.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const EventEmitter = require('events');
|
||||
const AssetUtil = require('../util/tw-asset-util');
|
||||
const StringUtil = require('../util/string-util');
|
||||
const log = require('../util/log');
|
||||
|
||||
/**
|
||||
* @typedef InternalFont
|
||||
* @property {boolean} system True if the font is built in to the system
|
||||
* @property {string} family The font's name
|
||||
* @property {string} fallback Fallback font family list
|
||||
* @property {Asset} [asset] scratch-storage asset if system: false
|
||||
*/
|
||||
|
||||
class FontManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Runtime} runtime
|
||||
*/
|
||||
constructor (runtime) {
|
||||
super();
|
||||
this.runtime = runtime;
|
||||
/** @type {Array<InternalFont>} */
|
||||
this.fonts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} family An unknown font family
|
||||
* @returns {boolean} true if the family is valid
|
||||
*/
|
||||
isValidFamily (family) {
|
||||
return /^[-\w ]+$/.test(family);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} family
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasFont (family) {
|
||||
return !!this.fonts.find(i => i.family === family);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} family
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getSafeName (family) {
|
||||
family = family.replace(/[^-\w ]/g, '');
|
||||
return StringUtil.unusedName(family, this.fonts.map(i => i.family));
|
||||
}
|
||||
|
||||
changed () {
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} family
|
||||
* @param {string} fallback
|
||||
*/
|
||||
addSystemFont (family, fallback) {
|
||||
if (!this.isValidFamily(family)) {
|
||||
throw new Error('Invalid family');
|
||||
}
|
||||
this.fonts.push({
|
||||
system: true,
|
||||
family,
|
||||
fallback
|
||||
});
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} family
|
||||
* @param {string} fallback
|
||||
* @param {Asset} asset scratch-storage asset
|
||||
*/
|
||||
addCustomFont (family, fallback, asset) {
|
||||
if (!this.isValidFamily(family)) {
|
||||
throw new Error('Invalid family');
|
||||
}
|
||||
|
||||
this.fonts.push({
|
||||
system: false,
|
||||
family,
|
||||
fallback,
|
||||
asset
|
||||
});
|
||||
|
||||
this.updateRenderer();
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<{system: boolean; name: string; family: string; data: Uint8Array | null; format: string | null}>}
|
||||
*/
|
||||
getFonts () {
|
||||
return this.fonts.map(font => ({
|
||||
system: font.system,
|
||||
name: font.family,
|
||||
family: `"${font.family}", ${font.fallback}`,
|
||||
data: font.asset ? font.asset.data : null,
|
||||
format: font.asset ? font.asset.dataFormat : null
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index Corresponds to index from getFonts()
|
||||
*/
|
||||
deleteFont (index) {
|
||||
const [removed] = this.fonts.splice(index, 1);
|
||||
if (!removed.system) {
|
||||
this.updateRenderer();
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
clear () {
|
||||
const hadNonSystemFont = this.fonts.some(i => !i.system);
|
||||
this.fonts = [];
|
||||
if (hadNonSystemFont) {
|
||||
this.updateRenderer();
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
updateRenderer () {
|
||||
if (!this.runtime.renderer || !this.runtime.renderer.setCustomFonts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fontfaces = {};
|
||||
for (const font of this.fonts) {
|
||||
if (!font.system) {
|
||||
const uri = font.asset.encodeDataURI();
|
||||
const fontface = `@font-face { font-family: "${font.family}"; src: url("${uri}"); }`;
|
||||
const family = `"${font.family}", ${font.fallback}`;
|
||||
fontfaces[family] = fontface;
|
||||
}
|
||||
}
|
||||
this.runtime.renderer.setCustomFonts(fontfaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data to save in project.json and sb3 files.
|
||||
*/
|
||||
serializeJSON () {
|
||||
if (this.fonts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.fonts.map(font => {
|
||||
const serialized = {
|
||||
system: font.system,
|
||||
family: font.family,
|
||||
fallback: font.fallback
|
||||
};
|
||||
|
||||
if (!font.system) {
|
||||
const asset = font.asset;
|
||||
serialized.md5ext = `${asset.assetId}.${asset.dataFormat}`;
|
||||
}
|
||||
|
||||
return serialized;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Asset[]} list of scratch-storage assets
|
||||
*/
|
||||
serializeAssets () {
|
||||
return this.fonts
|
||||
.filter(i => !i.system)
|
||||
.map(i => i.asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} json
|
||||
* @param {JSZip} [zip]
|
||||
* @param {boolean} [keepExisting]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deserialize (json, zip, keepExisting) {
|
||||
if (!keepExisting) {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
if (!Array.isArray(json)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const font of json) {
|
||||
if (!font || typeof font !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const system = font.system;
|
||||
const family = font.family;
|
||||
const fallback = font.fallback;
|
||||
if (
|
||||
typeof system !== 'boolean' ||
|
||||
typeof family !== 'string' ||
|
||||
typeof fallback !== 'string' ||
|
||||
this.hasFont(family)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (system) {
|
||||
this.addSystemFont(family, fallback);
|
||||
} else {
|
||||
const md5ext = font.md5ext;
|
||||
if (typeof md5ext !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = await AssetUtil.getByMd5ext(
|
||||
this.runtime,
|
||||
zip,
|
||||
this.runtime.storage.AssetType.Font,
|
||||
md5ext
|
||||
);
|
||||
this.addCustomFont(family, fallback, asset);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('could not add font', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FontManager;
|
||||
94
scratch-vm/src/engine/tw-frame-loop.js
Normal file
94
scratch-vm/src/engine/tw-frame-loop.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Due to the existence of features such as interpolation and "0 FPS" being treated as "screen refresh rate",
|
||||
// The VM loop logic has become much more complex
|
||||
|
||||
// Use setTimeout to polyfill requestAnimationFrame in Node.js environments
|
||||
const _requestAnimationFrame = typeof requestAnimationFrame === 'function' ?
|
||||
requestAnimationFrame :
|
||||
(f => setTimeout(f, 1000 / 60));
|
||||
const _cancelAnimationFrame = typeof requestAnimationFrame === 'function' ?
|
||||
cancelAnimationFrame :
|
||||
clearTimeout;
|
||||
|
||||
const animationFrameWrapper = callback => {
|
||||
let id;
|
||||
const handle = () => {
|
||||
id = _requestAnimationFrame(handle);
|
||||
callback();
|
||||
};
|
||||
const cancel = () => _cancelAnimationFrame(id);
|
||||
id = _requestAnimationFrame(handle);
|
||||
return {
|
||||
cancel
|
||||
};
|
||||
};
|
||||
|
||||
class FrameLoop {
|
||||
constructor (runtime) {
|
||||
this.runtime = runtime;
|
||||
this.running = false;
|
||||
this.setFramerate(30);
|
||||
this.setInterpolation(false);
|
||||
|
||||
this.stepCallback = this.stepCallback.bind(this);
|
||||
this.interpolationCallback = this.interpolationCallback.bind(this);
|
||||
|
||||
this._stepInterval = null;
|
||||
this._interpolationAnimation = null;
|
||||
this._stepAnimation = null;
|
||||
}
|
||||
|
||||
setFramerate (fps) {
|
||||
this.framerate = fps;
|
||||
this._restart();
|
||||
}
|
||||
|
||||
setInterpolation (interpolation) {
|
||||
this.interpolation = interpolation;
|
||||
this._restart();
|
||||
}
|
||||
|
||||
stepCallback () {
|
||||
this.runtime._step();
|
||||
}
|
||||
|
||||
interpolationCallback () {
|
||||
this.runtime._renderInterpolatedPositions();
|
||||
}
|
||||
|
||||
_restart () {
|
||||
if (this.running) {
|
||||
this.stop();
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
start () {
|
||||
this.running = true;
|
||||
if (this.framerate === 0) {
|
||||
this._stepAnimation = animationFrameWrapper(this.stepCallback);
|
||||
this.runtime.currentStepTime = 1000 / 60;
|
||||
} else {
|
||||
// Interpolation should never be enabled when framerate === 0 as that's just redundant
|
||||
if (this.interpolation) {
|
||||
this._interpolationAnimation = animationFrameWrapper(this.interpolationCallback);
|
||||
}
|
||||
this._stepInterval = setInterval(this.stepCallback, 1000 / this.framerate);
|
||||
this.runtime.currentStepTime = 1000 / this.framerate;
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
this.running = false;
|
||||
clearInterval(this._stepInterval);
|
||||
if (this._interpolationAnimation) {
|
||||
this._interpolationAnimation.cancel();
|
||||
}
|
||||
if (this._stepAnimation) {
|
||||
this._stepAnimation.cancel();
|
||||
}
|
||||
this._interpolationAnimation = null;
|
||||
this._stepAnimation = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FrameLoop;
|
||||
140
scratch-vm/src/engine/tw-interpolate.js
Normal file
140
scratch-vm/src/engine/tw-interpolate.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Prepare the targets of a runtime for interpolation.
|
||||
* @param {Runtime} runtime The Runtime with targets to prepare for interpolation.
|
||||
*/
|
||||
const setupInitialState = runtime => {
|
||||
const renderer = runtime.renderer;
|
||||
|
||||
for (const target of runtime.targets) {
|
||||
const directionAndScale = target._getRenderedDirectionAndScale();
|
||||
|
||||
// If sprite may have been interpolated in the previous frame, reset its renderer state.
|
||||
if (renderer && target.interpolationData) {
|
||||
const drawableID = target.drawableID;
|
||||
renderer.updateDrawablePosition(drawableID, [target.x, target.y]);
|
||||
renderer.updateDrawableDirectionScale(drawableID, directionAndScale.direction, directionAndScale.scale);
|
||||
renderer.updateDrawableEffect(drawableID, 'ghost', target.effects.ghost);
|
||||
}
|
||||
|
||||
if (target.visible && !target.isStage) {
|
||||
target.interpolationData = {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
direction: directionAndScale.direction,
|
||||
scale: directionAndScale.scale,
|
||||
costume: target.currentCostume,
|
||||
ghost: target.effects.ghost
|
||||
};
|
||||
} else {
|
||||
target.interpolationData = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolate the position of targets.
|
||||
* @param {Runtime} runtime The Runtime with targets to interpolate.
|
||||
* @param {number} time Relative time in the frame in [0-1].
|
||||
*/
|
||||
const interpolate = (runtime, time) => {
|
||||
const renderer = runtime.renderer;
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const target of runtime.targets) {
|
||||
// interpolationData is the initial state at the start of the frame (time 0)
|
||||
// the state on the target itself is the state at the end of the frame (time 1)
|
||||
const interpolationData = target.interpolationData;
|
||||
if (!interpolationData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't waste time interpolating sprites that are hidden.
|
||||
if (!target.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const drawableID = target.drawableID;
|
||||
|
||||
// Position interpolation.
|
||||
const xDistance = target.x - interpolationData.x;
|
||||
const yDistance = target.y - interpolationData.y;
|
||||
const absoluteXDistance = Math.abs(xDistance);
|
||||
const absoluteYDistance = Math.abs(yDistance);
|
||||
if (absoluteXDistance > 0.1 || absoluteYDistance > 0.1) {
|
||||
const drawable = renderer._allDrawables[drawableID];
|
||||
// Large movements are likely intended to be instantaneous.
|
||||
// getAABB is less accurate than getBounds, but it's much faster
|
||||
const bounds = drawable.getAABB();
|
||||
const tolerance = Math.min(240, Math.max(50, 1.5 * (bounds.width + bounds.height)));
|
||||
const distance = Math.sqrt((absoluteXDistance ** 2) + (absoluteYDistance ** 2));
|
||||
if (distance < tolerance) {
|
||||
const newX = interpolationData.x + (xDistance * time);
|
||||
const newY = interpolationData.y + (yDistance * time);
|
||||
renderer.updateDrawablePosition(drawableID, [newX, newY]);
|
||||
}
|
||||
}
|
||||
|
||||
// Effect interpolation.
|
||||
const ghostChange = target.effects.ghost - interpolationData.ghost;
|
||||
const absoluteGhostChange = Math.abs(ghostChange);
|
||||
// Large changes are likely intended to be instantaneous.
|
||||
if (absoluteGhostChange > 0 && absoluteGhostChange < 25) {
|
||||
const newGhost = target.effects.ghost + (ghostChange * time);
|
||||
renderer.updateDrawableEffect(drawableID, 'ghost', newGhost);
|
||||
}
|
||||
|
||||
// Interpolate scale and direction.
|
||||
const costumeUnchanged = interpolationData.costume === target.currentCostume;
|
||||
if (costumeUnchanged) {
|
||||
let {direction, scale} = target._getRenderedDirectionAndScale();
|
||||
let updateDrawableDirectionScale = false;
|
||||
|
||||
// Interpolate direction.
|
||||
if (direction !== interpolationData.direction) {
|
||||
// Perfect 90 degree angles should not be interpolated.
|
||||
// eg. the foreground tile clones in https://scratch.mit.edu/projects/60917032/
|
||||
if (direction % 90 !== 0 || interpolationData.direction % 90 !== 0) {
|
||||
const currentRadians = direction * Math.PI / 180;
|
||||
const startingRadians = interpolationData.direction * Math.PI / 180;
|
||||
direction = Math.atan2(
|
||||
(Math.sin(currentRadians) * time) + (Math.sin(startingRadians) * (1 - time)),
|
||||
(Math.cos(currentRadians) * time) + (Math.cos(startingRadians) * (1 - time))
|
||||
) * 180 / Math.PI;
|
||||
updateDrawableDirectionScale = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate scale.
|
||||
const startingScale = interpolationData.scale;
|
||||
if (scale[0] !== startingScale[0] || scale[1] !== startingScale[1]) {
|
||||
// Do not interpolate size when the sign of either scale differs.
|
||||
if (
|
||||
Math.sign(scale[0]) === Math.sign(startingScale[0]) &&
|
||||
Math.sign(scale[1]) === Math.sign(startingScale[1])
|
||||
) {
|
||||
const changeX = scale[0] - startingScale[0];
|
||||
const changeY = scale[1] - startingScale[1];
|
||||
const absoluteChangeX = Math.abs(changeX);
|
||||
const absoluteChangeY = Math.abs(changeY);
|
||||
// Large changes are likely intended to be instantaneous.
|
||||
if (absoluteChangeX < 100 && absoluteChangeY < 100) {
|
||||
scale[0] = startingScale[0] + (changeX * time);
|
||||
scale[1] = startingScale[1] + (changeY * time);
|
||||
updateDrawableDirectionScale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updateDrawableDirectionScale) {
|
||||
renderer.updateDrawableDirectionScale(drawableID, direction, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
setupInitialState,
|
||||
interpolate
|
||||
};
|
||||
7
scratch-vm/src/engine/tw-platform.js
Normal file
7
scratch-vm/src/engine/tw-platform.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Forks should change this.
|
||||
// This can be accessed externally on `vm.runtime.platform`
|
||||
|
||||
module.exports = {
|
||||
name: 'TurboWarp',
|
||||
url: 'https://turbowarp.org/'
|
||||
};
|
||||
70
scratch-vm/src/engine/variable.js
Normal file
70
scratch-vm/src/engine/variable.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* Object representing a Scratch variable.
|
||||
*/
|
||||
|
||||
const uid = require('../util/uid');
|
||||
const xmlEscape = require('../util/xml-escape');
|
||||
|
||||
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 (isLocal) {
|
||||
isLocal = (isLocal === true);
|
||||
return `<variable type="${this.type}" id="${this.id}" islocal="${isLocal
|
||||
}" iscloud="${this.isCloud}">${xmlEscape(this.name)}</variable>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representation for scalar variables.
|
||||
* This is currently represented as ''
|
||||
* for compatibility with blockly.
|
||||
* @const {string}
|
||||
*/
|
||||
static get SCALAR_TYPE () {
|
||||
return ''; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representation for list variables.
|
||||
* @const {string}
|
||||
*/
|
||||
static get LIST_TYPE () {
|
||||
return 'list'; // used by compiler
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representation for list variables.
|
||||
* @const {string}
|
||||
*/
|
||||
static get BROADCAST_MESSAGE_TYPE () {
|
||||
return 'broadcast_msg';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Variable;
|
||||
57
scratch-vm/src/extension-support/argument-type.js
Normal file
57
scratch-vm/src/extension-support/argument-type.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Block argument types
|
||||
* @enum {string}
|
||||
*/
|
||||
const ArgumentType = {
|
||||
/**
|
||||
* Numeric value with angle picker
|
||||
*/
|
||||
ANGLE: 'angle',
|
||||
|
||||
/**
|
||||
* Boolean value with hexagonal placeholder
|
||||
*/
|
||||
BOOLEAN: 'Boolean',
|
||||
|
||||
/**
|
||||
* Numeric value with color picker
|
||||
*/
|
||||
COLOR: 'color',
|
||||
|
||||
/**
|
||||
* Numeric value with text field
|
||||
*/
|
||||
NUMBER: 'number',
|
||||
|
||||
/**
|
||||
* String value with text field
|
||||
*/
|
||||
STRING: 'string',
|
||||
|
||||
/**
|
||||
* String value with matrix field
|
||||
*/
|
||||
MATRIX: 'matrix',
|
||||
|
||||
/**
|
||||
* MIDI note number with note picker (piano) field
|
||||
*/
|
||||
NOTE: 'note',
|
||||
|
||||
/**
|
||||
* Inline image on block (as part of the label)
|
||||
*/
|
||||
IMAGE: 'image',
|
||||
|
||||
/**
|
||||
* Name of costume in the current target
|
||||
*/
|
||||
COSTUME: 'costume',
|
||||
|
||||
/**
|
||||
* Name of sound in the current target
|
||||
*/
|
||||
SOUND: 'sound'
|
||||
};
|
||||
|
||||
module.exports = ArgumentType;
|
||||
60
scratch-vm/src/extension-support/block-type.js
Normal file
60
scratch-vm/src/extension-support/block-type.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Types of block
|
||||
* @enum {string}
|
||||
*/
|
||||
const BlockType = {
|
||||
/**
|
||||
* Boolean reporter with hexagonal shape
|
||||
*/
|
||||
BOOLEAN: 'Boolean',
|
||||
|
||||
/**
|
||||
* A button (not an actual block) for some special action, like making a variable
|
||||
*/
|
||||
BUTTON: 'button',
|
||||
|
||||
/**
|
||||
* A text label (not an actual block) for adding comments or labling blocks
|
||||
*/
|
||||
LABEL: 'label',
|
||||
|
||||
/**
|
||||
* Command block
|
||||
*/
|
||||
COMMAND: 'command',
|
||||
|
||||
/**
|
||||
* Specialized command block which may or may not run a child branch
|
||||
* The thread continues with the next block whether or not a child branch ran.
|
||||
*/
|
||||
CONDITIONAL: 'conditional',
|
||||
|
||||
/**
|
||||
* Specialized hat block with no implementation function
|
||||
* This stack only runs if the corresponding event is emitted by other code.
|
||||
*/
|
||||
EVENT: 'event',
|
||||
|
||||
/**
|
||||
* Hat block which conditionally starts a block stack
|
||||
*/
|
||||
HAT: 'hat',
|
||||
|
||||
/**
|
||||
* Specialized command block which may or may not run a child branch
|
||||
* If a child branch runs, the thread evaluates the loop block again.
|
||||
*/
|
||||
LOOP: 'loop',
|
||||
|
||||
/**
|
||||
* General reporter with numeric or string value
|
||||
*/
|
||||
REPORTER: 'reporter',
|
||||
|
||||
/**
|
||||
* Arbitrary scratch-blocks XML.
|
||||
*/
|
||||
XML: 'xml'
|
||||
};
|
||||
|
||||
module.exports = BlockType;
|
||||
18
scratch-vm/src/extension-support/define-messages.js
Normal file
18
scratch-vm/src/extension-support/define-messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @typedef {object} MessageDescriptor
|
||||
* @property {string} id - the translator-friendly unique ID of this message.
|
||||
* @property {string} default - the message text in the default language (English).
|
||||
* @property {string} [description] - a description of this message to help translators understand the context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is a hook for extracting messages from extension source files.
|
||||
* This function simply returns the message descriptor map object that's passed in.
|
||||
* @param {object.<MessageDescriptor>} messages - the messages to be defined
|
||||
* @return {object.<MessageDescriptor>} - the input, unprocessed
|
||||
*/
|
||||
const defineMessages = function (messages) {
|
||||
return messages;
|
||||
};
|
||||
|
||||
module.exports = defineMessages;
|
||||
609
scratch-vm/src/extension-support/extension-manager.js
Normal file
609
scratch-vm/src/extension-support/extension-manager.js
Normal file
@@ -0,0 +1,609 @@
|
||||
const dispatch = require('../dispatch/central-dispatch');
|
||||
const log = require('../util/log');
|
||||
const maybeFormatMessage = require('../util/maybe-format-message');
|
||||
|
||||
const BlockType = require('./block-type');
|
||||
const SecurityManager = require('./tw-security-manager');
|
||||
|
||||
// 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 defaultBuiltinExtensions = {
|
||||
// This is an example that isn't loaded with the other core blocks,
|
||||
// but serves as a reference for loading core blocks as extensions.
|
||||
coreExample: () => require('../blocks/scratch3_core_example'),
|
||||
// These are the non-core built-in extensions.
|
||||
pen: () => require('../extensions/scratch3_pen'),
|
||||
wedo2: () => require('../extensions/scratch3_wedo2'),
|
||||
music: () => require('../extensions/scratch3_music'),
|
||||
microbit: () => require('../extensions/scratch3_microbit'),
|
||||
text2speech: () => require('../extensions/scratch3_text2speech'),
|
||||
translate: () => require('../extensions/scratch3_translate'),
|
||||
videoSensing: () => require('../extensions/scratch3_video_sensing'),
|
||||
ev3: () => require('../extensions/scratch3_ev3'),
|
||||
makeymakey: () => require('../extensions/scratch3_makeymakey'),
|
||||
boost: () => require('../extensions/scratch3_boost'),
|
||||
gdxfor: () => require('../extensions/scratch3_gdx_for'),
|
||||
// tw: core extension
|
||||
tw: () => require('../extensions/tw')
|
||||
};
|
||||
|
||||
/**
|
||||
* @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} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks
|
||||
* @property {ExtensionBlockMetadata} info - the raw block info
|
||||
* @property {object} json - the scratch-blocks JSON definition for this block
|
||||
* @property {string} xml - the scratch-blocks XML definition for this block
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CategoryInfo - Information about a block category
|
||||
* @property {string} id - the unique ID of this category
|
||||
* @property {string} name - the human-readable name of this category
|
||||
* @property {string|undefined} blockIconURI - optional URI for the block icon image
|
||||
* @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.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category
|
||||
* @property {Array.<object>} menus - the menus provided by 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
|
||||
*/
|
||||
|
||||
const createExtensionService = extensionManager => {
|
||||
const service = {};
|
||||
service.registerExtensionServiceSync = extensionManager.registerExtensionServiceSync.bind(extensionManager);
|
||||
service.allocateWorker = extensionManager.allocateWorker.bind(extensionManager);
|
||||
service.onWorkerInit = extensionManager.onWorkerInit.bind(extensionManager);
|
||||
service.registerExtensionService = extensionManager.registerExtensionService.bind(extensionManager);
|
||||
return service;
|
||||
};
|
||||
|
||||
class ExtensionManager {
|
||||
constructor (vm) {
|
||||
/**
|
||||
* 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.<PendingExtensionWorker>}
|
||||
*/
|
||||
this.pendingExtensions = [];
|
||||
|
||||
/**
|
||||
* Map of worker ID to workers which have been allocated but have not yet finished initialization.
|
||||
* @type {Array.<PendingExtensionWorker>}
|
||||
*/
|
||||
this.pendingWorkers = [];
|
||||
|
||||
/**
|
||||
* Map of worker ID to the URL where it was loaded from.
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
this.workerURLs = [];
|
||||
|
||||
/**
|
||||
* Map of loaded extension URLs/IDs (equivalent for built-in extensions) to service name.
|
||||
* @type {Map.<string,string>}
|
||||
* @private
|
||||
*/
|
||||
this._loadedExtensions = new Map();
|
||||
|
||||
/**
|
||||
* Responsible for determining security policies related to custom extensions.
|
||||
*/
|
||||
this.securityManager = new SecurityManager();
|
||||
|
||||
/**
|
||||
* @type {VirtualMachine}
|
||||
*/
|
||||
this.vm = vm;
|
||||
|
||||
/**
|
||||
* 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 = vm.runtime;
|
||||
|
||||
this.loadingAsyncExtensions = 0;
|
||||
this.asyncExtensionsLoadedCallbacks = [];
|
||||
|
||||
this.builtinExtensions = Object.assign({}, defaultBuiltinExtensions);
|
||||
|
||||
dispatch.setService('extensions', createExtensionService(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension with a given ID is built in to the VM, such as pen.
|
||||
* Note that "core extensions" like motion will return false here.
|
||||
* @param {string} extensionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isBuiltinExtension (extensionId) {
|
||||
return Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously load an internal extension (core or non-core) by ID. This call will
|
||||
* fail if the provided id is not does not match an internal extension.
|
||||
* @param {string} extensionId - the ID of an internal extension
|
||||
*/
|
||||
loadExtensionIdSync (extensionId) {
|
||||
if (!this.isBuiltinExtension(extensionId)) {
|
||||
log.warn(`Could not find extension ${extensionId} in the built in extensions.`);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
|
||||
if (this.isExtensionLoaded(extensionId)) {
|
||||
const message = `Rejecting attempt to load a second extension with ID ${extensionId}`;
|
||||
log.warn(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = this.builtinExtensions[extensionId]();
|
||||
const extensionInstance = new extension(this.runtime);
|
||||
const serviceName = this._registerInternalExtension(extensionInstance);
|
||||
this._loadedExtensions.set(extensionId, serviceName);
|
||||
this.runtime.compilerRegisterExtension(extensionId, extensionInstance);
|
||||
}
|
||||
|
||||
addBuiltinExtension (extensionId, extensionClass) {
|
||||
this.builtinExtensions[extensionId] = () => extensionClass;
|
||||
}
|
||||
|
||||
_isValidExtensionURL (extensionURL) {
|
||||
try {
|
||||
const parsedURL = new URL(extensionURL);
|
||||
return (
|
||||
parsedURL.protocol === 'https:' ||
|
||||
parsedURL.protocol === 'http:' ||
|
||||
parsedURL.protocol === 'data:' ||
|
||||
parsedURL.protocol === 'file:'
|
||||
);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async loadExtensionURL (extensionURL) {
|
||||
if (this.isBuiltinExtension(extensionURL)) {
|
||||
this.loadExtensionIdSync(extensionURL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isExtensionURLLoaded(extensionURL)) {
|
||||
// Extension is already loaded.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isValidExtensionURL(extensionURL)) {
|
||||
throw new Error(`Invalid extension URL: ${extensionURL}`);
|
||||
}
|
||||
|
||||
this.runtime.setExternalCommunicationMethod('customExtensions', true);
|
||||
|
||||
this.loadingAsyncExtensions++;
|
||||
|
||||
const sandboxMode = await this.securityManager.getSandboxMode(extensionURL);
|
||||
const rewritten = await this.securityManager.rewriteExtensionURL(extensionURL);
|
||||
|
||||
if (sandboxMode === 'unsandboxed') {
|
||||
const {load} = require('./tw-unsandboxed-extension-runner');
|
||||
const extensionObjects = await load(rewritten, this.vm)
|
||||
.catch(error => this._failedLoadingExtensionScript(error));
|
||||
const fakeWorkerId = this.nextExtensionWorker++;
|
||||
this.workerURLs[fakeWorkerId] = extensionURL;
|
||||
|
||||
for (const extensionObject of extensionObjects) {
|
||||
const extensionInfo = extensionObject.getInfo();
|
||||
const serviceName = `unsandboxed.${fakeWorkerId}.${extensionInfo.id}`;
|
||||
dispatch.setServiceSync(serviceName, extensionObject);
|
||||
dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName);
|
||||
this._loadedExtensions.set(extensionInfo.id, serviceName);
|
||||
}
|
||||
|
||||
this._finishedLoadingExtensionScript();
|
||||
return;
|
||||
}
|
||||
|
||||
/* eslint-disable max-len */
|
||||
let ExtensionWorker;
|
||||
if (sandboxMode === 'worker') {
|
||||
ExtensionWorker = require('worker-loader?name=js/extension-worker/extension-worker.[hash].js!./extension-worker');
|
||||
} else if (sandboxMode === 'iframe') {
|
||||
ExtensionWorker = (await import(/* webpackChunkName: "iframe-extension-worker" */ './tw-iframe-extension-worker')).default;
|
||||
} else {
|
||||
throw new Error(`Invalid sandbox mode: ${sandboxMode}`);
|
||||
}
|
||||
/* eslint-enable max-len */
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingExtensions.push({extensionURL: rewritten, resolve, reject});
|
||||
dispatch.addWorker(new ExtensionWorker());
|
||||
}).catch(error => this._failedLoadingExtensionScript(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until all async extensions have loaded
|
||||
* @returns {Promise} resolved when all async extensions have loaded
|
||||
*/
|
||||
allAsyncExtensionsLoaded () {
|
||||
if (this.loadingAsyncExtensions === 0) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this.asyncExtensionsLoadedCallbacks.push({
|
||||
resolve,
|
||||
reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate blockinfo for any loaded extensions
|
||||
* @returns {Promise} resolved once all the extensions have been reinitialized
|
||||
*/
|
||||
refreshBlocks () {
|
||||
const allPromises = Array.from(this._loadedExtensions.values()).map(serviceName =>
|
||||
dispatch.call(serviceName, 'getInfo')
|
||||
.then(info => {
|
||||
info = this._prepareExtensionInfo(serviceName, info);
|
||||
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
|
||||
})
|
||||
.catch(e => {
|
||||
log.error('Failed to refresh built-in extension primitives', e);
|
||||
})
|
||||
);
|
||||
return Promise.all(allPromises);
|
||||
}
|
||||
|
||||
allocateWorker () {
|
||||
const id = this.nextExtensionWorker++;
|
||||
const workerInfo = this.pendingExtensions.shift();
|
||||
this.pendingWorkers[id] = workerInfo;
|
||||
this.workerURLs[id] = workerInfo.extensionURL;
|
||||
return [id, workerInfo.extensionURL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously collect extension metadata from the specified service and begin the extension registration process.
|
||||
* @param {string} serviceName - the name of the service hosting the extension.
|
||||
*/
|
||||
registerExtensionServiceSync (serviceName) {
|
||||
const info = dispatch.callSync(serviceName, 'getInfo');
|
||||
this._registerExtensionInfo(serviceName, info);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._loadedExtensions.set(info.id, serviceName);
|
||||
this._registerExtensionInfo(serviceName, info);
|
||||
this._finishedLoadingExtensionScript();
|
||||
});
|
||||
}
|
||||
|
||||
_finishedLoadingExtensionScript () {
|
||||
this.loadingAsyncExtensions--;
|
||||
if (this.loadingAsyncExtensions === 0) {
|
||||
this.asyncExtensionsLoadedCallbacks.forEach(i => i.resolve());
|
||||
this.asyncExtensionsLoadedCallbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
_failedLoadingExtensionScript (error) {
|
||||
// Don't set the current extension counter to 0, otherwise it will go negative if another
|
||||
// extension finishes or fails to load.
|
||||
this.loadingAsyncExtensions--;
|
||||
this.asyncExtensionsLoadedCallbacks.forEach(i => i.reject(error));
|
||||
this.asyncExtensionsLoadedCallbacks = [];
|
||||
// Re-throw error so the promise still rejects.
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an internal (non-Worker) extension object
|
||||
* @param {object} extensionObject - the extension object to register
|
||||
* @returns {string} The name of the registered extension service
|
||||
*/
|
||||
_registerInternalExtension (extensionObject) {
|
||||
const extensionInfo = extensionObject.getInfo();
|
||||
const fakeWorkerId = this.nextExtensionWorker++;
|
||||
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`;
|
||||
dispatch.setServiceSync(serviceName, extensionObject);
|
||||
dispatch.callSync('extensions', 'registerExtensionServiceSync', 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}:`, e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) {
|
||||
throw new Error('Invalid extension id');
|
||||
}
|
||||
extensionInfo.name = extensionInfo.name || extensionInfo.id;
|
||||
extensionInfo.blocks = extensionInfo.blocks || [];
|
||||
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
|
||||
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => {
|
||||
try {
|
||||
let result;
|
||||
switch (blockInfo) {
|
||||
case '---': // separator
|
||||
result = '---';
|
||||
break;
|
||||
default: // an ExtensionBlockMetadata object
|
||||
result = this._prepareBlockInfo(serviceName, blockInfo);
|
||||
break;
|
||||
}
|
||||
results.push(result);
|
||||
} catch (e) {
|
||||
// TODO: more meaningful error reporting
|
||||
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
|
||||
}
|
||||
return results;
|
||||
}, []);
|
||||
extensionInfo.menus = extensionInfo.menus || {};
|
||||
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
|
||||
return extensionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare extension menus. e.g. setup binding for dynamic menu functions.
|
||||
* @param {string} serviceName - the name of the service hosting this extension block
|
||||
* @param {Array.<MenuInfo>} menus - the menu defined by the extension.
|
||||
* @returns {Array.<MenuInfo>} - a menuInfo object with all preprocessing done.
|
||||
* @private
|
||||
*/
|
||||
_prepareMenuInfo (serviceName, menus) {
|
||||
const menuNames = Object.getOwnPropertyNames(menus);
|
||||
for (let i = 0; i < menuNames.length; i++) {
|
||||
const menuName = menuNames[i];
|
||||
let menuInfo = menus[menuName];
|
||||
|
||||
// If the menu description is in short form (items only) then normalize it to general form: an object with
|
||||
// its items listed in an `items` property.
|
||||
if (!menuInfo.items) {
|
||||
menuInfo = {
|
||||
items: menuInfo
|
||||
};
|
||||
menus[menuName] = menuInfo;
|
||||
}
|
||||
// If `items` is a string, it should be the name of a function in the extension object. Calling the
|
||||
// function should return an array of items to populate the menu when it is opened.
|
||||
if (typeof menuInfo.items === 'string') {
|
||||
const menuItemFunctionName = menuInfo.items;
|
||||
const serviceObject = dispatch.services[serviceName];
|
||||
// Bind the function here so we can pass a simple item generation function to Scratch Blocks later.
|
||||
menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName);
|
||||
}
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the items for a particular extension menu, providing the target ID for context.
|
||||
* @param {object} extensionObject - the extension object providing the menu.
|
||||
* @param {string} menuItemFunctionName - the name of the menu function to call.
|
||||
* @returns {Array} menu items ready for scratch-blocks.
|
||||
* @private
|
||||
*/
|
||||
_getExtensionMenuItems (extensionObject, menuItemFunctionName) {
|
||||
// Fetch the items appropriate for the target currently being edited. This assumes that menus only
|
||||
// collect items when opened by the user while editing a particular target.
|
||||
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
|
||||
const editingTargetID = editingTarget ? editingTarget.id : null;
|
||||
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
|
||||
|
||||
// TODO: Fix this to use dispatch.call when extensions are running in workers.
|
||||
const menuFunc = extensionObject[menuItemFunctionName];
|
||||
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
|
||||
item => {
|
||||
item = maybeFormatMessage(item, extensionMessageContext);
|
||||
switch (typeof item) {
|
||||
case 'object':
|
||||
return [
|
||||
maybeFormatMessage(item.text, extensionMessageContext),
|
||||
item.value
|
||||
];
|
||||
case 'string':
|
||||
return [item, item];
|
||||
default:
|
||||
return item;
|
||||
}
|
||||
});
|
||||
|
||||
if (!menuItems || menuItems.length < 1) {
|
||||
throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`);
|
||||
}
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply defaults for optional block fields.
|
||||
* @param {string} serviceName - the name of the service hosting this extension block
|
||||
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension
|
||||
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields.
|
||||
* @private
|
||||
*/
|
||||
_prepareBlockInfo (serviceName, blockInfo) {
|
||||
if (blockInfo.blockType === BlockType.XML) {
|
||||
blockInfo = Object.assign({}, blockInfo);
|
||||
blockInfo.xml = String(blockInfo.xml) || '';
|
||||
return blockInfo;
|
||||
}
|
||||
|
||||
blockInfo = Object.assign({}, {
|
||||
blockType: BlockType.COMMAND,
|
||||
terminal: false,
|
||||
blockAllThreads: false,
|
||||
arguments: {}
|
||||
}, blockInfo);
|
||||
blockInfo.text = blockInfo.text || blockInfo.opcode;
|
||||
|
||||
switch (blockInfo.blockType) {
|
||||
case BlockType.EVENT:
|
||||
if (blockInfo.func) {
|
||||
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
|
||||
}
|
||||
break;
|
||||
case BlockType.BUTTON:
|
||||
if (blockInfo.opcode) {
|
||||
log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`);
|
||||
}
|
||||
blockInfo.callFunc = () => {
|
||||
dispatch.call(serviceName, blockInfo.func);
|
||||
};
|
||||
break;
|
||||
case BlockType.LABEL:
|
||||
if (blockInfo.opcode) {
|
||||
log.warn(`Ignoring opcode "${blockInfo.opcode}" for label: ${blockInfo.text}`);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
if (!blockInfo.opcode) {
|
||||
throw new Error('Missing opcode for block');
|
||||
}
|
||||
|
||||
const funcName = blockInfo.func || blockInfo.opcode;
|
||||
|
||||
const getBlockInfo = blockInfo.isDynamic ?
|
||||
args => args && args.mutation && args.mutation.blockInfo :
|
||||
() => blockInfo;
|
||||
const callBlockFunc = (() => {
|
||||
if (dispatch._isRemoteService(serviceName)) {
|
||||
return (args, util, realBlockInfo) =>
|
||||
dispatch.call(serviceName, funcName, args, util, realBlockInfo)
|
||||
.then(result => {
|
||||
// Scratch is only designed to handle these types.
|
||||
// If any other value comes in such as undefined, null, an object, etc.
|
||||
// we'll convert it to a string to avoid undefined behavior.
|
||||
if (
|
||||
typeof result === 'number' ||
|
||||
typeof result === 'string' ||
|
||||
typeof result === 'boolean'
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
return `${result}`;
|
||||
});
|
||||
}
|
||||
|
||||
// avoid promise latency if we can call direct
|
||||
const serviceObject = dispatch.services[serviceName];
|
||||
if (!serviceObject[funcName]) {
|
||||
// The function might show up later as a dynamic property of the service object
|
||||
log.warn(`Could not find extension block function called ${funcName}`);
|
||||
}
|
||||
return (args, util, realBlockInfo) =>
|
||||
serviceObject[funcName](args, util, realBlockInfo);
|
||||
})();
|
||||
|
||||
blockInfo.func = (args, util) => {
|
||||
const realBlockInfo = getBlockInfo(args);
|
||||
// TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed?
|
||||
return callBlockFunc(args, util, realBlockInfo);
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return blockInfo;
|
||||
}
|
||||
|
||||
getExtensionURLs () {
|
||||
const extensionURLs = {};
|
||||
for (const [extensionId, serviceName] of this._loadedExtensions.entries()) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Service names for extension workers are in the format "extension.WORKER_ID.EXTENSION_ID"
|
||||
const workerId = +serviceName.split('.')[1];
|
||||
const extensionURL = this.workerURLs[workerId];
|
||||
if (typeof extensionURL === 'string') {
|
||||
extensionURLs[extensionId] = extensionURL;
|
||||
}
|
||||
}
|
||||
return extensionURLs;
|
||||
}
|
||||
|
||||
isExtensionURLLoaded (url) {
|
||||
return Object.values(this.workerURLs).includes(url);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExtensionManager;
|
||||
64
scratch-vm/src/extension-support/extension-metadata.js
Normal file
64
scratch-vm/src/extension-support/extension-metadata.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @typedef {object} ExtensionMetadata
|
||||
* All the metadata needed to register an extension.
|
||||
* @property {string} id - a unique alphanumeric identifier for this extension. No special characters allowed.
|
||||
* @property {string} [name] - the human-readable name of this extension.
|
||||
* @property {string} [blockIconURI] - URI for an image to be placed on each block in this extension. Data URI ok.
|
||||
* @property {string} [menuIconURI] - URI for an image to be placed on this extension's category menu item. Data URI ok.
|
||||
* @property {string} [docsURI] - link to documentation content for this extension.
|
||||
* @property {Array.<ExtensionBlockMetadata|string>} blocks - the blocks provided by this extension, plus separators.
|
||||
* @property {Object.<ExtensionMenuMetadata>} [menus] - map of menu name to metadata for each of this extension's menus.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionBlockMetadata
|
||||
* All the metadata needed to register an extension block.
|
||||
* @property {string} opcode - a unique alphanumeric identifier for this block. No special characters allowed.
|
||||
* @property {string} [func] - the name of the function implementing this block. Can be shared by other blocks/opcodes.
|
||||
* @property {BlockType} blockType - the type of block (command, reporter, etc.) being described.
|
||||
* @property {string} text - the text on the block, with [PLACEHOLDERS] for arguments.
|
||||
* @property {Boolean} [hideFromPalette] - true if this block should not appear in the block palette.
|
||||
* @property {Boolean} [isTerminal] - true if the block ends a stack - no blocks can be connected after it.
|
||||
* @property {Boolean} [disableMonitor] - true if this block is a reporter but should not allow a monitor.
|
||||
* @property {ReporterScope} [reporterScope] - if this block is a reporter, this is the scope/context for its value.
|
||||
* @property {Boolean} [isEdgeActivated] - sets whether a hat block is edge-activated.
|
||||
* @property {Boolean} [shouldRestartExistingThreads] - sets whether a hat/event block should restart existing threads.
|
||||
* @property {int} [branchCount] - for flow control blocks, the number of branches/substacks for this block.
|
||||
* @property {Object.<ExtensionArgumentMetadata>} [arguments] - map of argument placeholder to metadata about each arg.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionArgumentMetadata
|
||||
* All the metadata needed to register an argument for an extension block.
|
||||
* @property {ArgumentType} type - the type of the argument (number, string, etc.)
|
||||
* @property {*} [defaultValue] - the default value of this argument.
|
||||
* @property {string} [menu] - the name of the menu to use for this argument, if any.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {ExtensionDynamicMenu|ExtensionMenuItems} ExtensionMenuMetadata
|
||||
* All the metadata needed to register an extension drop-down menu.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {string} ExtensionDynamicMenu
|
||||
* The string name of a function which returns menu items.
|
||||
* @see {ExtensionMenuItems} - the type of data expected to be returned by the specified function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Array.<ExtensionMenuItemSimple|ExtensionMenuItemComplex>} ExtensionMenuItems
|
||||
* Items in an extension menu.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {string} ExtensionMenuItemSimple
|
||||
* A menu item for which the label and value are identical strings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionMenuItemComplex
|
||||
* A menu item for which the label and value can differ.
|
||||
* @property {*} value - the value of the block argument when this menu item is selected.
|
||||
* @property {string} text - the human-readable label of this menu item in the menu.
|
||||
*/
|
||||
100
scratch-vm/src/extension-support/extension-worker.js
Normal file
100
scratch-vm/src/extension-support/extension-worker.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/* eslint-env worker */
|
||||
|
||||
const ScratchCommon = require('./tw-extension-api-common');
|
||||
const createScratchX = require('./tw-scratchx-compatibility-layer');
|
||||
const dispatch = require('../dispatch/worker-dispatch');
|
||||
const log = require('../util/log');
|
||||
const {isWorker} = require('./tw-extension-worker-context');
|
||||
const createTranslate = require('./tw-l10n');
|
||||
|
||||
const translate = createTranslate(null);
|
||||
|
||||
const loadScripts = url => {
|
||||
if (isWorker) {
|
||||
importScripts(url);
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
reject(new Error(`Error in sandboxed script: ${url}. Check the console for more information.`));
|
||||
};
|
||||
script.src = url;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
class ExtensionWorker {
|
||||
constructor () {
|
||||
this.nextExtensionId = 0;
|
||||
|
||||
this.initialRegistrations = [];
|
||||
|
||||
this.firstRegistrationPromise = new Promise(resolve => {
|
||||
this.firstRegistrationCallback = resolve;
|
||||
});
|
||||
|
||||
dispatch.waitForConnection.then(() => {
|
||||
dispatch.call('extensions', 'allocateWorker').then(async x => {
|
||||
const [id, extension] = x;
|
||||
this.workerId = id;
|
||||
|
||||
try {
|
||||
await loadScripts(extension);
|
||||
await this.firstRegistrationPromise;
|
||||
|
||||
const initialRegistrations = this.initialRegistrations;
|
||||
this.initialRegistrations = null;
|
||||
|
||||
Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id));
|
||||
} catch (e) {
|
||||
log.error(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.firstRegistrationCallback();
|
||||
this.initialRegistrations.push(promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
global.Scratch = global.Scratch || {};
|
||||
Object.assign(global.Scratch, ScratchCommon, {
|
||||
canFetch: () => Promise.resolve(true),
|
||||
fetch: (url, options) => fetch(url, options),
|
||||
canOpenWindow: () => Promise.resolve(false),
|
||||
openWindow: () => Promise.reject(new Error('Scratch.openWindow not supported in sandboxed extensions')),
|
||||
canRedirect: () => Promise.resolve(false),
|
||||
redirect: () => Promise.reject(new Error('Scratch.redirect not supported in sandboxed extensions')),
|
||||
canRecordAudio: () => Promise.resolve(false),
|
||||
canRecordVideo: () => Promise.resolve(false),
|
||||
canReadClipboard: () => Promise.resolve(false),
|
||||
canNotify: () => Promise.resolve(false),
|
||||
canGeolocate: () => Promise.resolve(false),
|
||||
canEmbed: () => Promise.resolve(false),
|
||||
translate
|
||||
});
|
||||
|
||||
/**
|
||||
* Expose only specific parts of the worker to extensions.
|
||||
*/
|
||||
const extensionWorker = new ExtensionWorker();
|
||||
global.Scratch.extensions = {
|
||||
register: extensionWorker.register.bind(extensionWorker)
|
||||
};
|
||||
|
||||
global.ScratchExtensions = createScratchX(global.Scratch);
|
||||
18
scratch-vm/src/extension-support/reporter-scope.js
Normal file
18
scratch-vm/src/extension-support/reporter-scope.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Indicate the scope for a reporter's value.
|
||||
* @enum {string}
|
||||
*/
|
||||
const ReporterScope = {
|
||||
/**
|
||||
* This reporter's value is global and does not depend on context.
|
||||
*/
|
||||
GLOBAL: 'global',
|
||||
|
||||
/**
|
||||
* This reporter's value is specific to a particular target/sprite.
|
||||
* Another target may have a different value or may not even have a value.
|
||||
*/
|
||||
TARGET: 'target'
|
||||
};
|
||||
|
||||
module.exports = ReporterScope;
|
||||
17
scratch-vm/src/extension-support/target-type.js
Normal file
17
scratch-vm/src/extension-support/target-type.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Default types of Target supported by the VM
|
||||
* @enum {string}
|
||||
*/
|
||||
const TargetType = {
|
||||
/**
|
||||
* Rendered target which can move, change costumes, etc.
|
||||
*/
|
||||
SPRITE: 'sprite',
|
||||
|
||||
/**
|
||||
* Rendered target which cannot move but can change backdrops
|
||||
*/
|
||||
STAGE: 'stage'
|
||||
};
|
||||
|
||||
module.exports = TargetType;
|
||||
@@ -0,0 +1,15 @@
|
||||
// If a project uses an extension but does not specify a URL, it will default to
|
||||
// the URLs given here, if it exists. This is useful for compatibility with other mods.
|
||||
|
||||
const defaults = new Map();
|
||||
|
||||
// Box2D (`griffpatch`) is not listed here because our extension is not actually
|
||||
// compatible with the original version due to fields vs inputs.
|
||||
|
||||
// Scratch Lab Animated Text - https://lab.scratch.mit.edu/text/
|
||||
defaults.set('text', 'https://extensions.turbowarp.org/lab/text.js');
|
||||
|
||||
// Turboloader's AudioStream
|
||||
defaults.set('audiostr', 'https://extensions.turbowarp.org/turboloader/audiostream.js');
|
||||
|
||||
module.exports = defaults;
|
||||
13
scratch-vm/src/extension-support/tw-extension-api-common.js
Normal file
13
scratch-vm/src/extension-support/tw-extension-api-common.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const ArgumentType = require('./argument-type');
|
||||
const BlockType = require('./block-type');
|
||||
const TargetType = require('./target-type');
|
||||
const Cast = require('../util/cast');
|
||||
|
||||
const Scratch = {
|
||||
ArgumentType,
|
||||
BlockType,
|
||||
TargetType,
|
||||
Cast
|
||||
};
|
||||
|
||||
module.exports = Scratch;
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
isWorker: true,
|
||||
// centralDispatchService is the object to call postMessage() on to send a message to parent.
|
||||
centralDispatchService: self
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const context = require('./tw-extension-worker-context');
|
||||
|
||||
const jQuery = require('./tw-jquery-shim');
|
||||
global.$ = jQuery;
|
||||
global.jQuery = jQuery;
|
||||
|
||||
const id = window.__WRAPPED_IFRAME_ID__;
|
||||
|
||||
context.isWorker = false;
|
||||
context.centralDispatchService = {
|
||||
postMessage (message, transfer) {
|
||||
const data = {
|
||||
vmIframeId: id,
|
||||
message
|
||||
};
|
||||
if (transfer) {
|
||||
window.parent.postMessage(data, '*', transfer);
|
||||
} else {
|
||||
window.parent.postMessage(data, '*');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
require('./extension-worker');
|
||||
|
||||
window.parent.postMessage({
|
||||
vmIframeId: id,
|
||||
ready: true
|
||||
}, '*');
|
||||
@@ -0,0 +1,96 @@
|
||||
const uid = require('../util/uid');
|
||||
const frameSource = require('./tw-load-script-as-plain-text!./tw-iframe-extension-worker-entry');
|
||||
|
||||
const none = "'none'";
|
||||
const featurePolicy = {
|
||||
'accelerometer': none,
|
||||
'ambient-light-sensor': none,
|
||||
'battery': none,
|
||||
'camera': none,
|
||||
'display-capture': none,
|
||||
'document-domain': none,
|
||||
'encrypted-media': none,
|
||||
'fullscreen': none,
|
||||
'geolocation': none,
|
||||
'gyroscope': none,
|
||||
'magnetometer': none,
|
||||
'microphone': none,
|
||||
'midi': none,
|
||||
'payment': none,
|
||||
'picture-in-picture': none,
|
||||
'publickey-credentials-get': none,
|
||||
'speaker-selection': none,
|
||||
'usb': none,
|
||||
'vibrate': none,
|
||||
'vr': none,
|
||||
'screen-wake-lock': none,
|
||||
'web-share': none,
|
||||
'interest-cohort': none
|
||||
};
|
||||
|
||||
const generateAllow = () => Object.entries(featurePolicy)
|
||||
.map(([name, permission]) => `${name} ${permission}`)
|
||||
.join('; ');
|
||||
|
||||
class IframeExtensionWorker {
|
||||
constructor () {
|
||||
this.id = uid();
|
||||
this.isRemote = true;
|
||||
this.ready = false;
|
||||
this.queuedMessages = [];
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.className = 'tw-custom-extension-frame';
|
||||
this.iframe.dataset.id = this.id;
|
||||
this.iframe.style.display = 'none';
|
||||
this.iframe.setAttribute('aria-hidden', 'true');
|
||||
this.iframe.sandbox = 'allow-scripts';
|
||||
this.iframe.allow = generateAllow();
|
||||
document.body.appendChild(this.iframe);
|
||||
|
||||
window.addEventListener('message', this._onWindowMessage.bind(this));
|
||||
const blob = new Blob([
|
||||
// eslint-disable-next-line max-len
|
||||
`<!DOCTYPE html><body><script>window.__WRAPPED_IFRAME_ID__=${JSON.stringify(this.id)};${frameSource}</script></body>`
|
||||
], {
|
||||
type: 'text/html; charset=utf-8'
|
||||
});
|
||||
this.iframe.src = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
_onWindowMessage (e) {
|
||||
if (!e.data || e.data.vmIframeId !== this.id) {
|
||||
return;
|
||||
}
|
||||
if (e.data.ready) {
|
||||
this.ready = true;
|
||||
for (const {data, transfer} of this.queuedMessages) {
|
||||
this.postMessage(data, transfer);
|
||||
}
|
||||
this.queuedMessages.length = 0;
|
||||
}
|
||||
if (e.data.message) {
|
||||
this.onmessage({
|
||||
data: e.data.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onmessage () {
|
||||
// Should be overridden
|
||||
}
|
||||
|
||||
postMessage (data, transfer) {
|
||||
if (this.ready) {
|
||||
if (transfer) {
|
||||
this.iframe.contentWindow.postMessage(data, '*', transfer);
|
||||
} else {
|
||||
this.iframe.contentWindow.postMessage(data, '*');
|
||||
}
|
||||
} else {
|
||||
this.queuedMessages.push({data, transfer});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IframeExtensionWorker;
|
||||
112
scratch-vm/src/extension-support/tw-jquery-shim.js
Normal file
112
scratch-vm/src/extension-support/tw-jquery-shim.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* Many ScratchX extensions require jQuery to do things like loading scripts and making requests.
|
||||
* The real jQuery is pretty large and we'd rather not bring in everything, so this file reimplements
|
||||
* small stubs of a few jQuery methods.
|
||||
* It's just supposed to be enough to make existing ScratchX extensions work, nothing more.
|
||||
*/
|
||||
|
||||
const log = require('../util/log');
|
||||
|
||||
const jQuery = () => {
|
||||
throw new Error('Not implemented');
|
||||
};
|
||||
|
||||
jQuery.getScript = (src, callback) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
if (callback) {
|
||||
// We don't implement callback arguments.
|
||||
script.onload = () => callback();
|
||||
}
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>|undefined} obj
|
||||
* @returns {URLSearchParams}
|
||||
*/
|
||||
const objectToQueryString = obj => {
|
||||
const params = new URLSearchParams();
|
||||
if (obj) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
params.set(key, obj[key]);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
let jsonpCallback = 0;
|
||||
|
||||
jQuery.ajax = async (arg1, arg2) => {
|
||||
let options = {};
|
||||
|
||||
if (arg1 && arg2) {
|
||||
options = arg2;
|
||||
options.url = arg1;
|
||||
} else if (arg1) {
|
||||
options = arg1;
|
||||
}
|
||||
|
||||
const urlParameters = objectToQueryString(options.data);
|
||||
const getFinalURL = () => {
|
||||
const query = urlParameters.toString();
|
||||
let url = options.url;
|
||||
if (query) {
|
||||
url += `?${query}`;
|
||||
}
|
||||
// Forcibly upgrade all HTTP requests to HTTPS so that they don't error on HTTPS sites
|
||||
// All the extensions we care about work fine with this
|
||||
if (url.startsWith('http://')) {
|
||||
url = url.replace('http://', 'https://');
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const successCallback = result => {
|
||||
if (options.success) {
|
||||
options.success(result);
|
||||
}
|
||||
};
|
||||
const errorCallback = error => {
|
||||
log.error(error);
|
||||
if (options.error) {
|
||||
// The error object we provide here might not match what jQuery provides but it's enough to
|
||||
// prevent extensions from throwing errors trying to access properties.
|
||||
options.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (options.dataType === 'jsonp') {
|
||||
const callbackName = `_jsonp_callback${jsonpCallback++}`;
|
||||
global[callbackName] = data => {
|
||||
delete global[callbackName];
|
||||
successCallback(data);
|
||||
};
|
||||
|
||||
const callbackParameterName = options.jsonp || 'callback';
|
||||
urlParameters.set(callbackParameterName, callbackName);
|
||||
|
||||
jQuery.getScript(getFinalURL());
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dataType === 'script') {
|
||||
jQuery.getScript(getFinalURL(), successCallback);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(getFinalURL(), {
|
||||
headers: options.headers
|
||||
});
|
||||
// dataType defaults to "Intelligent Guess (xml, json, script, or html)"
|
||||
// It happens that all the ScratchX extensions we care about either set dataType to "json" or
|
||||
// leave it blank and implicitly request JSON, so this works good enough for now.
|
||||
successCallback(await res.json());
|
||||
} catch (e) {
|
||||
errorCallback(e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = jQuery;
|
||||
61
scratch-vm/src/extension-support/tw-l10n.js
Normal file
61
scratch-vm/src/extension-support/tw-l10n.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const formatMessage = require('format-message');
|
||||
|
||||
/**
|
||||
* @param {VM|null} vm
|
||||
* @returns {object}
|
||||
*/
|
||||
const createTranslate = vm => {
|
||||
const namespace = formatMessage.namespace();
|
||||
|
||||
const translate = (message, args) => {
|
||||
if (message && typeof message === 'object') {
|
||||
// already in the expected format
|
||||
} else if (typeof message === 'string') {
|
||||
message = {
|
||||
default: message
|
||||
};
|
||||
} else {
|
||||
throw new Error('unsupported data type in translate()');
|
||||
}
|
||||
return namespace(message, args);
|
||||
};
|
||||
|
||||
const generateId = defaultMessage => `_${defaultMessage}`;
|
||||
|
||||
const getLocale = () => {
|
||||
if (vm) return vm.getLocale();
|
||||
if (typeof navigator !== 'undefined') return navigator.language;
|
||||
return 'en';
|
||||
};
|
||||
|
||||
let storedTranslations = {};
|
||||
translate.setup = newTranslations => {
|
||||
if (newTranslations) {
|
||||
storedTranslations = newTranslations;
|
||||
}
|
||||
namespace.setup({
|
||||
locale: getLocale(),
|
||||
missingTranslation: 'ignore',
|
||||
generateId,
|
||||
translations: storedTranslations
|
||||
});
|
||||
};
|
||||
|
||||
Object.defineProperty(translate, 'language', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => getLocale()
|
||||
});
|
||||
|
||||
translate.setup({});
|
||||
|
||||
if (vm) {
|
||||
vm.on('LOCALE_CHANGED', () => {
|
||||
translate.setup(null);
|
||||
});
|
||||
}
|
||||
|
||||
return translate;
|
||||
};
|
||||
|
||||
module.exports = createTranslate;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Based on https://github.com/webpack-contrib/worker-loader/tree/v2.0.0
|
||||
|
||||
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
|
||||
|
||||
module.exports.pitch = function (request) {
|
||||
// Technically this loader does work in other environments, but our use case does not want that.
|
||||
if (this.target !== 'web') {
|
||||
return 'throw new Error("Not supported in non-web environment");';
|
||||
}
|
||||
this.cacheable(false);
|
||||
const callback = this.async();
|
||||
const compiler = this._compilation.createChildCompiler('extension worker', {});
|
||||
new SingleEntryPlugin(this.context, `!!${request}`, 'extension worker').apply(compiler);
|
||||
compiler.runAsChild((err, entries, compilation) => {
|
||||
if (err) return callback(err);
|
||||
const file = entries[0].files[0];
|
||||
const source = `module.exports = ${JSON.stringify(compilation.assets[file].source())};`;
|
||||
return callback(null, source);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
// ScratchX API Documentation: https://github.com/LLK/scratchx/wiki/
|
||||
|
||||
const ArgumentType = require('./argument-type');
|
||||
const BlockType = require('./block-type');
|
||||
|
||||
const {
|
||||
argumentIndexToId,
|
||||
generateExtensionId
|
||||
} = require('./tw-scratchx-utilities');
|
||||
|
||||
/**
|
||||
* @typedef ScratchXDescriptor
|
||||
* @property {unknown[][]} blocks
|
||||
* @property {Record<string, unknown[]>} [menus]
|
||||
* @property {string} [url]
|
||||
* @property {string} [displayName]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef ScratchXStatus
|
||||
* @property {0|1|2} status 0 is red/error, 1 is yellow/not ready, 2 is green/ready
|
||||
* @property {string} msg
|
||||
*/
|
||||
|
||||
const parseScratchXBlockType = type => {
|
||||
if (type === '' || type === ' ' || type === 'w') {
|
||||
return {
|
||||
type: BlockType.COMMAND,
|
||||
async: type === 'w'
|
||||
};
|
||||
}
|
||||
if (type === 'r' || type === 'R') {
|
||||
return {
|
||||
type: BlockType.REPORTER,
|
||||
async: type === 'R'
|
||||
};
|
||||
}
|
||||
if (type === 'b') {
|
||||
return {
|
||||
type: BlockType.BOOLEAN,
|
||||
// ScratchX docs don't seem to mention boolean reporters that wait
|
||||
async: false
|
||||
};
|
||||
}
|
||||
if (type === 'h') {
|
||||
return {
|
||||
type: BlockType.HAT,
|
||||
async: false
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown ScratchX block type: ${type}`);
|
||||
};
|
||||
|
||||
const isScratchCompatibleValue = v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
||||
|
||||
/**
|
||||
* @param {string} argument ScratchX argument with leading % removed.
|
||||
* @param {unknown} defaultValue Default value, if any
|
||||
*/
|
||||
const parseScratchXArgument = (argument, defaultValue) => {
|
||||
const result = {};
|
||||
const hasDefaultValue = isScratchCompatibleValue(defaultValue);
|
||||
|
||||
// defaultValue is ignored for booleans in Scratch 3
|
||||
if (hasDefaultValue && argument !== 'b') {
|
||||
result.defaultValue = defaultValue;
|
||||
}
|
||||
|
||||
if (argument === 's') {
|
||||
result.type = ArgumentType.STRING;
|
||||
if (!hasDefaultValue) {
|
||||
result.defaultValue = '';
|
||||
}
|
||||
} else if (argument === 'n') {
|
||||
result.type = ArgumentType.NUMBER;
|
||||
if (!hasDefaultValue) {
|
||||
result.defaultValue = 0;
|
||||
}
|
||||
} else if (argument[0] === 'm') {
|
||||
result.type = ArgumentType.STRING;
|
||||
const split = argument.split(/\.|:/);
|
||||
const menuName = split[1];
|
||||
result.menu = menuName;
|
||||
} else if (argument === 'b') {
|
||||
result.type = ArgumentType.BOOLEAN;
|
||||
} else {
|
||||
throw new Error(`Unknown ScratchX argument type: ${argument}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const wrapScratchXFunction = (originalFunction, argumentCount, async) => args => {
|
||||
// Convert Scratch 3's argument object to an argument list expected by ScratchX
|
||||
const argumentList = [];
|
||||
for (let i = 0; i < argumentCount; i++) {
|
||||
argumentList.push(args[argumentIndexToId(i)]);
|
||||
}
|
||||
if (async) {
|
||||
return new Promise(resolve => {
|
||||
originalFunction(...argumentList, resolve);
|
||||
});
|
||||
}
|
||||
return originalFunction(...argumentList);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {ScratchXDescriptor} descriptor
|
||||
* @param {Record<string, () => unknown>} functions
|
||||
*/
|
||||
const convert = (name, descriptor, functions) => {
|
||||
const extensionId = generateExtensionId(name);
|
||||
const info = {
|
||||
id: extensionId,
|
||||
name: descriptor.displayName || name,
|
||||
blocks: [],
|
||||
color1: '#4a4a5e',
|
||||
color2: '#31323f',
|
||||
color3: '#191a21'
|
||||
};
|
||||
const scratch3Extension = {
|
||||
getInfo: () => info,
|
||||
_getStatus: functions._getStatus
|
||||
};
|
||||
|
||||
if (descriptor.url) {
|
||||
info.docsURI = descriptor.url;
|
||||
}
|
||||
|
||||
for (const blockDescriptor of descriptor.blocks) {
|
||||
if (blockDescriptor.length === 1) {
|
||||
// Separator
|
||||
info.blocks.push('---');
|
||||
continue;
|
||||
}
|
||||
const scratchXBlockType = blockDescriptor[0];
|
||||
const blockText = blockDescriptor[1];
|
||||
const functionName = blockDescriptor[2];
|
||||
const defaultArgumentValues = blockDescriptor.slice(3);
|
||||
|
||||
let scratchText = '';
|
||||
const argumentInfo = [];
|
||||
const blockTextParts = blockText.split(/%([\w.:]+)/g);
|
||||
for (let i = 0; i < blockTextParts.length; i++) {
|
||||
const part = blockTextParts[i];
|
||||
const isArgument = i % 2 === 1;
|
||||
if (isArgument) {
|
||||
parseScratchXArgument(part);
|
||||
const argumentIndex = Math.floor(i / 2).toString();
|
||||
const argumentDefaultValue = defaultArgumentValues[argumentIndex];
|
||||
const argumentId = argumentIndexToId(argumentIndex);
|
||||
argumentInfo[argumentId] = parseScratchXArgument(part, argumentDefaultValue);
|
||||
scratchText += `[${argumentId}]`;
|
||||
} else {
|
||||
scratchText += part;
|
||||
}
|
||||
}
|
||||
|
||||
const scratch3BlockType = parseScratchXBlockType(scratchXBlockType);
|
||||
const blockInfo = {
|
||||
opcode: functionName,
|
||||
blockType: scratch3BlockType.type,
|
||||
text: scratchText,
|
||||
arguments: argumentInfo
|
||||
};
|
||||
info.blocks.push(blockInfo);
|
||||
|
||||
const originalFunction = functions[functionName];
|
||||
const argumentCount = argumentInfo.length;
|
||||
scratch3Extension[functionName] = wrapScratchXFunction(
|
||||
originalFunction,
|
||||
argumentCount,
|
||||
scratch3BlockType.async
|
||||
);
|
||||
}
|
||||
|
||||
const menus = descriptor.menus;
|
||||
if (menus) {
|
||||
const scratch3Menus = {};
|
||||
for (const menuName of Object.keys(menus) || {}) {
|
||||
const menuItems = menus[menuName];
|
||||
const menuInfo = {
|
||||
items: menuItems
|
||||
};
|
||||
scratch3Menus[menuName] = menuInfo;
|
||||
}
|
||||
info.menus = scratch3Menus;
|
||||
}
|
||||
|
||||
return scratch3Extension;
|
||||
};
|
||||
|
||||
const extensionNameToExtension = new Map();
|
||||
|
||||
/**
|
||||
* @param {*} Scratch Scratch 3.0 extension API object
|
||||
* @returns {*} ScratchX-compatible API object
|
||||
*/
|
||||
const createScratchX = Scratch => {
|
||||
const register = (name, descriptor, functions) => {
|
||||
const scratch3Extension = convert(name, descriptor, functions);
|
||||
extensionNameToExtension.set(name, scratch3Extension);
|
||||
Scratch.extensions.register(scratch3Extension);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} extensionName
|
||||
* @returns {ScratchXStatus}
|
||||
*/
|
||||
const getStatus = extensionName => {
|
||||
const extension = extensionNameToExtension.get(extensionName);
|
||||
if (extension) {
|
||||
return extension._getStatus();
|
||||
}
|
||||
return {
|
||||
status: 0,
|
||||
msg: 'does not exist'
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
getStatus
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = createScratchX;
|
||||
25
scratch-vm/src/extension-support/tw-scratchx-utilities.js
Normal file
25
scratch-vm/src/extension-support/tw-scratchx-utilities.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* General ScratchX-related utilities used in multiple places.
|
||||
* Changing these functions may break projects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} scratchXName
|
||||
* @returns {string}
|
||||
*/
|
||||
const generateExtensionId = scratchXName => {
|
||||
const sanitizedName = scratchXName.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
return `sbx${sanitizedName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} i 0-indexed index of argument in list
|
||||
* @returns {string} Scratch 3 argument name
|
||||
*/
|
||||
const argumentIndexToId = i => i.toString();
|
||||
|
||||
module.exports = {
|
||||
generateExtensionId,
|
||||
argumentIndexToId
|
||||
};
|
||||
157
scratch-vm/src/extension-support/tw-security-manager.js
Normal file
157
scratch-vm/src/extension-support/tw-security-manager.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
/**
|
||||
* Responsible for determining various policies related to custom extension security.
|
||||
* The default implementation prevents automatic extension loading, but grants any
|
||||
* loaded extensions the maximum possible capabilities so as to retain compatibility
|
||||
* with a vanilla scratch-vm. You may override properties of an instance of this class
|
||||
* to customize the security policies as you see fit, for example:
|
||||
* ```js
|
||||
* vm.securityManager.getSandboxMode = (url) => {
|
||||
* if (url.startsWith("https://example.com/")) {
|
||||
* return "unsandboxed";
|
||||
* }
|
||||
* return "iframe";
|
||||
* };
|
||||
* vm.securityManager.canAutomaticallyLoadExtension = (url) => {
|
||||
* return confirm("Automatically load extension: " + url);
|
||||
* };
|
||||
* vm.securityManager.canFetch = (url) => {
|
||||
* return url.startsWith('https://turbowarp.org/');
|
||||
* };
|
||||
* vm.securityManager.canOpenWindow = (url) => {
|
||||
* return url.startsWith('https://turbowarp.org/');
|
||||
* };
|
||||
* vm.securityManager.canRedirect = (url) => {
|
||||
* return url.startsWith('https://turbowarp.org/');
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
class SecurityManager {
|
||||
/**
|
||||
* Determine the typeof sandbox to use for a certain custom extension.
|
||||
* @param {string} extensionURL The URL of the custom extension.
|
||||
* @returns {'worker'|'iframe'|'unsandboxed'|Promise<'worker'|'iframe'|'unsandboxed'>}
|
||||
*/
|
||||
getSandboxMode (extensionURL) {
|
||||
// Default to worker for Scratch compatibility
|
||||
return Promise.resolve('worker');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a custom extension that was stored inside a project may be
|
||||
* loaded. You could, for example, ask the user to confirm loading an extension
|
||||
* before resolving.
|
||||
* @param {string} extensionURL The URL of the custom extension.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canLoadExtensionFromProject (extensionURL) {
|
||||
// Default to false for security
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows last-minute changing the real URL of the extension that gets loaded.
|
||||
* @param {*} extensionURL The URL requested to be loaded.
|
||||
* @returns {Promise<string>|string} The URL to actually load.
|
||||
*/
|
||||
rewriteExtensionURL (extensionURL) {
|
||||
return Promise.resolve(extensionURL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to fetch a remote resource URL.
|
||||
* This only applies to unsandboxed extensions that use the appropriate Scratch.* APIs.
|
||||
* Sandboxed extensions ignore this entirely as there is no way to force them to use our APIs.
|
||||
* data: and blob: URLs are always allowed (this method is never called).
|
||||
* @param {string} resourceURL
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canFetch (resourceURL) {
|
||||
// By default, allow any requests.
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to open a new window or tab to a given URL.
|
||||
* This only applies to unsandboxed extensions. Sandboxed extensions are unable to open windows.
|
||||
* javascript: URLs are always rejected (this method is never called).
|
||||
* @param {string} websiteURL
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canOpenWindow (websiteURL) {
|
||||
// By default, allow all.
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to redirect the current tab to a given URL.
|
||||
* This only applies to unsandboxed extensions. Sandboxed extensions are unable to redirect the parent
|
||||
* window, but are free to redirect their own sandboxed window.
|
||||
* javascript: URLs are always rejected (this method is never called).
|
||||
* @param {string} websiteURL
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canRedirect (websiteURL) {
|
||||
// By default, allow all.
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to record audio from the user's microphone.
|
||||
* This could include raw audio data or a transcriptions.
|
||||
* Note that, even if this returns true, success is not guaranteed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canRecordAudio () {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to record video from the user's camera.
|
||||
* Note that, even if this returns true, success is not guaranteed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canRecordVideo () {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to read values from the user's clipboard
|
||||
* without user interaction.
|
||||
* Note that, even if this returns true, success is not guaranteed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canReadClipboard () {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to show notifications.
|
||||
* Note that, even if this returns true, success is not guaranteed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canNotify () {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to find the user's precise location using GPS
|
||||
* and other techniques. Note that, even if this returns true, success is not guaranteed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canGeolocate () {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an extension is allowed to embed content from a given URL.
|
||||
* @param {string} documentURL The URL of the embed.
|
||||
* @returns {Promise<boolean>|boolean}
|
||||
*/
|
||||
canEmbed (documentURL) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SecurityManager;
|
||||
@@ -0,0 +1,177 @@
|
||||
const ScratchCommon = require('./tw-extension-api-common');
|
||||
const createScratchX = require('./tw-scratchx-compatibility-layer');
|
||||
const AsyncLimiter = require('../util/async-limiter');
|
||||
const createTranslate = require('./tw-l10n');
|
||||
const staticFetch = require('../util/tw-static-fetch');
|
||||
|
||||
/* eslint-disable require-await */
|
||||
|
||||
/**
|
||||
* Parse a URL object or return null.
|
||||
* @param {string} url
|
||||
* @returns {URL|null}
|
||||
*/
|
||||
const parseURL = url => {
|
||||
try {
|
||||
return new URL(url, location.href);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the global.Scratch API for an unsandboxed extension.
|
||||
* @param {VirtualMachine} vm
|
||||
* @returns {Promise<object[]>} Resolves with a list of extension objects when Scratch.extensions.register is called.
|
||||
*/
|
||||
const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => {
|
||||
const extensionObjects = [];
|
||||
const register = extensionObject => {
|
||||
extensionObjects.push(extensionObject);
|
||||
resolve(extensionObjects);
|
||||
};
|
||||
|
||||
// Create a new copy of global.Scratch for each extension
|
||||
const Scratch = Object.assign({}, global.Scratch || {}, ScratchCommon);
|
||||
Scratch.extensions = {
|
||||
unsandboxed: true,
|
||||
register
|
||||
};
|
||||
Scratch.vm = vm;
|
||||
Scratch.renderer = vm.runtime.renderer;
|
||||
|
||||
Scratch.canFetch = async url => {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
// Always allow protocols that don't involve a remote request.
|
||||
if (parsed.protocol === 'blob:' || parsed.protocol === 'data:') {
|
||||
return true;
|
||||
}
|
||||
return vm.securityManager.canFetch(parsed.href);
|
||||
};
|
||||
|
||||
Scratch.canOpenWindow = async url => {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
// Always reject protocols that would allow code execution.
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (parsed.protocol === 'javascript:') {
|
||||
return false;
|
||||
}
|
||||
return vm.securityManager.canOpenWindow(parsed.href);
|
||||
};
|
||||
|
||||
Scratch.canRedirect = async url => {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
// Always reject protocols that would allow code execution.
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (parsed.protocol === 'javascript:') {
|
||||
return false;
|
||||
}
|
||||
return vm.securityManager.canRedirect(parsed.href);
|
||||
};
|
||||
|
||||
Scratch.canRecordAudio = async () => vm.securityManager.canRecordAudio();
|
||||
|
||||
Scratch.canRecordVideo = async () => vm.securityManager.canRecordVideo();
|
||||
|
||||
Scratch.canReadClipboard = async () => vm.securityManager.canReadClipboard();
|
||||
|
||||
Scratch.canNotify = async () => vm.securityManager.canNotify();
|
||||
|
||||
Scratch.canGeolocate = async () => vm.securityManager.canGeolocate();
|
||||
|
||||
Scratch.canEmbed = async url => {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return vm.securityManager.canEmbed(parsed.href);
|
||||
};
|
||||
|
||||
Scratch.fetch = async (url, options) => {
|
||||
const actualURL = url instanceof Request ? url.url : url;
|
||||
|
||||
const staticFetchResult = staticFetch(url);
|
||||
if (staticFetchResult) {
|
||||
return staticFetchResult;
|
||||
}
|
||||
|
||||
if (!await Scratch.canFetch(actualURL)) {
|
||||
throw new Error(`Permission to fetch ${actualURL} rejected.`);
|
||||
}
|
||||
return fetch(url, options);
|
||||
};
|
||||
|
||||
Scratch.openWindow = async (url, features) => {
|
||||
if (!await Scratch.canOpenWindow(url)) {
|
||||
throw new Error(`Permission to open tab ${url} rejected.`);
|
||||
}
|
||||
// Use noreferrer to prevent new tab from accessing `window.opener`
|
||||
const baseFeatures = 'noreferrer';
|
||||
features = features ? `${baseFeatures},${features}` : baseFeatures;
|
||||
return window.open(url, '_blank', features);
|
||||
};
|
||||
|
||||
Scratch.redirect = async url => {
|
||||
if (!await Scratch.canRedirect(url)) {
|
||||
throw new Error(`Permission to redirect to ${url} rejected.`);
|
||||
}
|
||||
location.href = url;
|
||||
};
|
||||
|
||||
Scratch.translate = createTranslate(vm);
|
||||
|
||||
global.Scratch = Scratch;
|
||||
global.ScratchExtensions = createScratchX(Scratch);
|
||||
|
||||
vm.emit('CREATE_UNSANDBOXED_EXTENSION_API', Scratch);
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable the existing global.Scratch unsandboxed extension APIs.
|
||||
* This helps debug poorly designed extensions.
|
||||
*/
|
||||
const teardownUnsandboxedExtensionAPI = () => {
|
||||
// We can assume global.Scratch already exists.
|
||||
global.Scratch.extensions.register = () => {
|
||||
throw new Error('Too late to register new extensions.');
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load an unsandboxed extension from an arbitrary URL. This is dangerous.
|
||||
* @param {string} extensionURL
|
||||
* @param {Virtualmachine} vm
|
||||
* @returns {Promise<object[]>} Resolves with a list of extension objects if the extension was loaded successfully.
|
||||
*/
|
||||
const loadUnsandboxedExtension = (extensionURL, vm) => new Promise((resolve, reject) => {
|
||||
setupUnsandboxedExtensionAPI(vm).then(resolve);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.onerror = () => {
|
||||
reject(new Error(`Error in unsandboxed script ${extensionURL}. Check the console for more information.`));
|
||||
};
|
||||
script.src = extensionURL;
|
||||
document.body.appendChild(script);
|
||||
}).then(objects => {
|
||||
teardownUnsandboxedExtensionAPI();
|
||||
return objects;
|
||||
});
|
||||
|
||||
// Because loading unsandboxed extensions requires messing with global state (global.Scratch),
|
||||
// only let one extension load at a time.
|
||||
const limiter = new AsyncLimiter(loadUnsandboxedExtension, 1);
|
||||
const load = (extensionURL, vm) => limiter.do(extensionURL, vm);
|
||||
|
||||
module.exports = {
|
||||
setupUnsandboxedExtensionAPI,
|
||||
load
|
||||
};
|
||||
2113
scratch-vm/src/extensions/scratch3_boost/index.js
Normal file
2113
scratch-vm/src/extensions/scratch3_boost/index.js
Normal file
File diff suppressed because it is too large
Load Diff
1355
scratch-vm/src/extensions/scratch3_ev3/index.js
Normal file
1355
scratch-vm/src/extensions/scratch3_ev3/index.js
Normal file
File diff suppressed because one or more lines are too long
981
scratch-vm/src/extensions/scratch3_gdx_for/index.js
Normal file
981
scratch-vm/src/extensions/scratch3_gdx_for/index.js
Normal file
@@ -0,0 +1,981 @@
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
const BlockType = require('../../extension-support/block-type');
|
||||
const log = require('../../util/log');
|
||||
const formatMessage = require('format-message');
|
||||
const MathUtil = require('../../util/math-util');
|
||||
const BLE = require('../../io/ble');
|
||||
const godirect = require('@vernier/godirect/dist/godirect.min.umd.js');
|
||||
const ScratchLinkDeviceAdapter = require('./scratch-link-device-adapter');
|
||||
|
||||
/**
|
||||
* Icon png 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAABGdBTUEAALGPC/xhBQAACCNJREFUeAHtnGtsFFUUgM+dfXbbbbcWaKHSFgrlkWgkJCb6A4kmJfiHIBYBpcFfRg1GEkmEVAvhFYw/TExMxGoICAECiZEIIUQCiiT4gh+KILRQCi2ENIV2t/ue6zl3u2Upu4XuzO4csCe587iPmXO/OWfunTszV4ABWfflQU+0p+9bTcLzEmS5gUPlvagAcVMXcMpnK1u+evW8QLYKaNkWpHKxnt6dQsqFjxo80p10Jt1vx7t30n62Ys+2IJUTUpDlqUNomgYutwsjhZFD5r6slBAOhUHX9YTe6D1GTmrIAhFeBZ2c4JFCpBiggmwlBR7pTGLUewxZYBIUWV7yqgb7g8lotuukt5ihqyELHCSEbusk931ExMxbjSkWSNxEyr3vysxZLFHWnDuT0CtFV6OKmmOBRrV4hMubZoGmMZA6lHTfgsLeHnBEIiCxUY86XRDw+sBfOgZ0m820U5lxIFYAncF+GNvVDo5QaLBu1ClyYTyF4tvd8lZltQgXFA6mW73BxoVt0ShUXG2VCp4QQdDEFqez4Bm7p7gaO0of422r3x4Ji/KrbdIexu4SE2FjgWO6OkCLx6gt6gxOiNV92tiY+ni1Ye1nu7dpQfk35ikru9EBN6unsEDIwgLJPQv8dwCfT3WPt+iFIfAUqM3vL7vpjmuz0KX1gkAfOMN33dxKkjwA9vsTDIS8uubdBZcyAWlqWtohQbRSuru/L1O2vMazAGiLxRKVFqDgDEdAaHCN0kU8Ply2vKWxABhzJZ5ipC6qHlRzfJxVz99S49GdYQEw7PYkuAmokZJ6fumlQUqiNpVSQ56i9JnyHMsCYMRdADGHk0ZyHM1b976XicH0rXtWYR57FPNSGQ7CAiCBCJQ8oXhI0FdmBiPfVnl9ZZmz5DmFDcA+HwIUOEYMcjL2+e57PbBp04HxONI4ifIEKC8TYQMwhs+7IU+hwBFOYQvB5qF8grbwJnRfQXnIhbkIG4AExF+ScE00w0X3AZLwisrDyH1JH1YAA8UlIG029FRZsu6TPfVJiIltWYIjMTLgLUlGs1izeRYmGtS383t9wnu7G2J6fH/Tln2LNUdExGLxvZSOQ1qCS/+P9CFhBZAUuj12PHgCvRJHZ7w4EnhYjya6hXGHQ2Jaxj4ilbVC2AFEUNBVXSdKb3WC29+rmISKiqFn7ARBadyEHUACFHM64VZlDTdWafVh1Yik1ZB5JEsLJGaVtosw37ld4TscWQHX4+oRWO1zWrAEWCR6oMnTCEXijmI1234MVvsPgV+WcmKndGHpwlNtZwbhkZYEkuI4CkuAXfpk0HGAPym0TXEchaUL39Br4JvQeljk+lwxOxBeCRQ3UrFHI+AMBsEV6gcnhlwIS4BU0RORV1V42EqnwnLgSyo3AsM3eA9bPOt8bAEOV6NUWGRZ9FYvHSx6R0pfYgkMmk2DCH1+Z7KwB5gKazjLGgpLgUOAuRZWALnDSncxLAOYCmskbqjhe02h5d6y0sFKF5cXgI8LrLwB9PTeGew6POwNnptlpYOVLi4nFjjuWts957rnBk8tomoZ+bjhPcqOcCcnAG34EaTqOjxmsNKxzQnAkX5wronsOry6zIn66ThljLNcg+W1a2Gi55+MCg6XcKl3NuxrbxouS87TLAcY1V0QV5+8jLyuEekeeSGTS1gOcM/lZpOrlN/DsRzOyi8CY2fLuwUum/wR1BT+ZUzrDKUv9D4LB9rXZEjNTfRjZYFS5r86ebfA3W0bcmMKFh01/5fMoorm6rSjAA2SNc2F8dvmQVWCgdy8fxg8gcEN0pWez80QUyyQFAqn/N9mhmK5PAYN7adecCPnMsUCCZ7U8ari4IGb87wJeKFDA/MlmHXBDVkgTR1CV4/gaThKzBoeKYpuSzqSrqSzEiFuJDayWxqyQJp3RUhYSKfWUSEz5iDIrhrZl8I5b37JvrTBT3wdpd43cOqT/WiJhq6ikQpkW5a8BxuS/X219uXZHoPKmdMUGdEgpWzTll3Kr95Z8VJK7N3NL7b/qHY2rnmdjd6G7oF3q/b/3RoFaPDajwIcBWiQgMHioxZoEKChfqDBc2csnmxtM2ZglMDKArFvduhBbLDv9sOD8oymA0xBCHVtl6+c7ey6Ibdt+3ox7WOoxMCmD4i68PrZkBQaEDUe1tnVqSyyfl79+vr6evz1C2jKogkYWEEc0JnViiZRqKuoqJiZtEJcn0GIsykewzhW2jJVZjzBamxsfK79ase/5MoXL106TnEDwfq36qgIF6HGjKyqFsNkDGMwUNxEDEmIHQTxyNGjH1AchvumBcC4vAuXVpiA+TDYMFDXiiZFoN+SrmMI7tixo/v3337diNtQUzNpPq1RChIra5ccAFKDUEwYLra2fnXu3PmtA0gojqbaVUNl23ft+pPiPW73U7RGYdGH5QCQYCg93C73075S34I5c+ZQa0s/B1Njou51tVVVatJAXcrED3Q4EI5plgsHgAQiSiRCoRD9ECeam9fPo32UJzFQYwJLlix9mdZ9fb1naY2iyiQ2rVtyAEi199Pi5M8/tdB62vRpzceOH3+toaHBh61w2clTp96sqq5ehUnxw0eO7KA8KKpMYtO6JZcOKTUeNRhsp0+ffmtilYI1VLf4+Qvn1784d+5ezEfW144hMR05blglpDgHSbqxt6Wl5Y8ZM6afKq8oL7LZHd54PH7H7w+cOPj9dx8uXbLk+ICynbhm4cJDr7LVMKmhoP5dphaWoFGrHMTAQrgBJCjkFdQHpPntqCUmiWCge14PBsvdFnUYlP8AMAKfKIKmYukAAAAASUVORK5CYII=';
|
||||
|
||||
/**
|
||||
* Icon png to be displayed in the blocks category menu, encoded as a data URI.
|
||||
* @type {string}
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
const menuIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAA9dJREFUWAnNmE2IFEcUgF/9dE/v7LoaM9kkK4JBRA0EFBIPRm85hBAvEXHXwyo5eFE87GFcReMkObgJiQnkkJzEg9n8HIJixKNe1IMKihgiCbviwV11V3d0d3pmuqsqr5ppcEnb3TNVggVFVVe9eu+r97qqq4tASqp8/fsboQgmU0TMugi571K29bPy9ovPU8Sf16HbpQj3EkYFBcJcr5Am2nZfs94AIWVfqMQeHNwhICUBZ4ypUIA/X2sbIm2AW8AJK0lkEP6TJpfqwXgg4QxmF/fB7Gtvxk1G5ZKHU1CqTgPJoSUXYJYeohSUJu+qrqdVUGh2/pVX4VFffx77WaqBZkrkEFj271+qWH0sXcU3FBzyQe/Mg7B//LbKMTRTxNiDbsMHHjTJlyM7HEJIBHXs2KXFj+oTNSdoQOCYLS5jD9IwBMm5H8NplwwPb/QV4yEIcycaAza9IuA76B38fuz1OF5RXUkmHCdu6rg0BpSMgV/sAe7DdzGFrvvdi0D3mSZjQA0wt7REQsY+iWF0XbfFzyal8SLRxuteD+Du4h4Z/flbqaBHibAQtZmQtcZaAZSMwtTylaR/4vaw1ju5YhWG10pwwAqghmp2FeHO2+t11WqyM80W0m7vAOhsM1kD7CGz8L57Jsq6bitZC/GcWgLf1H6KuHT92cTDAFy/BgXMXm0OCpgV50Bo9kK3BqiBboabQMMU/WoL5im4jToeq/AIgXsiRx5KKCjcwPEsiAv/BQMu9EwyDHXd/3kqCOSzDk6t5/YglQKKeJwq+PNRmJI8kwSTaj1HZy5AhSHqnXkIvU9mMUwEw4Q5wTM57LUtkg8QPw/cdcBJ+PhvKJ0Gj80nGq6JXrg6/XFiX97GXIBpyqTieKpKViOl+WEhWXMaUavvvdIZ8Giy5+Lh3bwKm/t+Be3JazMfxc1tldY26rastiHcsQevTG9pw0znovkAcRWHzSDKnZtaOJLSfMFLB5RqtRBS4LbCurqLCy0YPkU3C0IIPEimMqR2ei7ZX2+KQdRi/WahNT/GmfOD4Vyzhx/66pcjp85dUvcmp6J8+txldXh07PPskdkS+V6EbD0vTOKlB0x9B/O6BS8ULly9PgE6x4kDPR/XX5pyYKj8xcCucsUmkNUQE0JvKKm2VioVK5HRE7UKOHbi6B94RzP+93jtpC0vWgXUF0hr3ipuw8uadwd3jXxoA9IK4Pah8t6BneV9GgjD28Svw1mlxFobgFbeFTz13cKbth93fDryp2CEq0a4hTA+aAPQ/ESJFDdvXLzzzrqNjlTqOP6uDeFf0uhvJ0ZP2QD8D6ZzU6u8YIbBAAAAAElFTkSuQmCC';
|
||||
|
||||
/**
|
||||
* Enum for Vernier godirect protocol.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const BLEUUID = {
|
||||
service: 'd91714ef-28b9-4f91-ba16-f0d9a604f112',
|
||||
commandChar: 'f4bf14a6-c7d5-4b6d-8aa8-df1a7c83adcb',
|
||||
responseChar: 'b41e6675-a329-40e0-aa01-44d2f444babe'
|
||||
};
|
||||
|
||||
/**
|
||||
* A time interval to wait (in milliseconds) before reporting to the BLE socket
|
||||
* that data has stopped coming from the peripheral.
|
||||
*/
|
||||
const BLETimeout = 4500;
|
||||
|
||||
/**
|
||||
* A string to report to the BLE socket when the GdxFor has stopped receiving data.
|
||||
* @type {string}
|
||||
*/
|
||||
const BLEDataStoppedError = 'Force and Acceleration extension stopped receiving data';
|
||||
|
||||
/**
|
||||
* Sensor ID numbers for the GDX-FOR.
|
||||
*/
|
||||
const GDXFOR_SENSOR = {
|
||||
FORCE: 1,
|
||||
ACCELERATION_X: 2,
|
||||
ACCELERATION_Y: 3,
|
||||
ACCELERATION_Z: 4,
|
||||
SPIN_SPEED_X: 5,
|
||||
SPIN_SPEED_Y: 6,
|
||||
SPIN_SPEED_Z: 7
|
||||
};
|
||||
|
||||
/**
|
||||
* The update rate, in milliseconds, for sensor data input from the peripheral.
|
||||
*/
|
||||
const GDXFOR_UPDATE_RATE = 80;
|
||||
|
||||
/**
|
||||
* Threshold for pushing and pulling force, for the whenForcePushedOrPulled hat block.
|
||||
* @type {number}
|
||||
*/
|
||||
const FORCE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Threshold for acceleration magnitude, for the "shaken" gesture.
|
||||
* @type {number}
|
||||
*/
|
||||
const SHAKEN_THRESHOLD = 30;
|
||||
|
||||
/**
|
||||
* Threshold for acceleration magnitude, to check if we are facing up.
|
||||
* @type {number}
|
||||
*/
|
||||
const FACING_THRESHOLD = 9;
|
||||
|
||||
/**
|
||||
* An offset for the facing threshold, used to check that we are no longer facing up.
|
||||
* @type {number}
|
||||
*/
|
||||
const FACING_THRESHOLD_OFFSET = 5;
|
||||
|
||||
/**
|
||||
* Threshold for acceleration magnitude, below which we are in freefall.
|
||||
* @type {number}
|
||||
*/
|
||||
const FREEFALL_THRESHOLD = 0.5;
|
||||
|
||||
/**
|
||||
* Factor used to account for influence of rotation during freefall.
|
||||
* @type {number}
|
||||
*/
|
||||
const FREEFALL_ROTATION_FACTOR = 0.3;
|
||||
|
||||
/**
|
||||
* Threshold in degrees for reporting that the sensor is tilted.
|
||||
* @type {number}
|
||||
*/
|
||||
const TILT_THRESHOLD = 15;
|
||||
|
||||
/**
|
||||
* Acceleration due to gravity, in m/s^2.
|
||||
* @type {number}
|
||||
*/
|
||||
const GRAVITY = 9.8;
|
||||
|
||||
/**
|
||||
* Manage communication with a GDX-FOR peripheral over a Scratch Link client socket.
|
||||
*/
|
||||
class GdxFor {
|
||||
|
||||
/**
|
||||
* Construct a GDX-FOR communication object.
|
||||
* @param {Runtime} runtime - the Scratch 3.0 runtime
|
||||
* @param {string} extensionId - the id of the extension
|
||||
*/
|
||||
constructor (runtime, extensionId) {
|
||||
|
||||
/**
|
||||
* The Scratch 3.0 runtime used to trigger the green flag button.
|
||||
* @type {Runtime}
|
||||
* @private
|
||||
*/
|
||||
this._runtime = runtime;
|
||||
|
||||
/**
|
||||
* The BluetoothLowEnergy connection socket for reading/writing peripheral data.
|
||||
* @type {BLE}
|
||||
* @private
|
||||
*/
|
||||
this._ble = null;
|
||||
|
||||
/**
|
||||
* An @vernier/godirect Device
|
||||
* @type {Device}
|
||||
* @private
|
||||
*/
|
||||
this._device = null;
|
||||
|
||||
this._runtime.registerPeripheralExtension(extensionId, this);
|
||||
|
||||
/**
|
||||
* The id of the extension this peripheral belongs to.
|
||||
*/
|
||||
this._extensionId = extensionId;
|
||||
|
||||
/**
|
||||
* The most recently received value for each sensor.
|
||||
* @type {Object.<string, number>}
|
||||
* @private
|
||||
*/
|
||||
this._sensors = {
|
||||
force: 0,
|
||||
accelerationX: 0,
|
||||
accelerationY: 0,
|
||||
accelerationZ: 0,
|
||||
spinSpeedX: 0,
|
||||
spinSpeedY: 0,
|
||||
spinSpeedZ: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Interval ID for data reading timeout.
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._timeoutID = null;
|
||||
|
||||
this.reset = this.reset.bind(this);
|
||||
this._onConnect = this._onConnect.bind(this);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called by the runtime when user wants to scan for a peripheral.
|
||||
*/
|
||||
scan () {
|
||||
if (this._ble) {
|
||||
this._ble.disconnect();
|
||||
}
|
||||
|
||||
this._ble = new BLE(this._runtime, this._extensionId, {
|
||||
filters: [
|
||||
{namePrefix: 'GDX-FOR'}
|
||||
],
|
||||
optionalServices: [
|
||||
BLEUUID.service
|
||||
]
|
||||
}, this._onConnect, this.reset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the runtime when user wants to connect to a certain peripheral.
|
||||
* @param {number} id - the id of the peripheral to connect to.
|
||||
*/
|
||||
connect (id) {
|
||||
if (this._ble) {
|
||||
this._ble.connectPeripheral(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the runtime when a user exits the connection popup.
|
||||
* Disconnect from the GDX FOR.
|
||||
*/
|
||||
disconnect () {
|
||||
if (this._ble) {
|
||||
this._ble.disconnect();
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all the state and timeout/interval ids.
|
||||
*/
|
||||
reset () {
|
||||
this._sensors = {
|
||||
force: 0,
|
||||
accelerationX: 0,
|
||||
accelerationY: 0,
|
||||
accelerationZ: 0,
|
||||
spinSpeedX: 0,
|
||||
spinSpeedY: 0,
|
||||
spinSpeedZ: 0
|
||||
};
|
||||
|
||||
if (this._timeoutID) {
|
||||
window.clearInterval(this._timeoutID);
|
||||
this._timeoutID = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if connected to the goforce device.
|
||||
* @return {boolean} - whether the goforce is connected.
|
||||
*/
|
||||
isConnected () {
|
||||
let connected = false;
|
||||
if (this._ble) {
|
||||
connected = this._ble.isConnected();
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading data from peripheral after BLE has connected to it.
|
||||
* @private
|
||||
*/
|
||||
_onConnect () {
|
||||
const adapter = new ScratchLinkDeviceAdapter(this._ble, BLEUUID);
|
||||
godirect.createDevice(adapter, {open: true, startMeasurements: false}).then(device => {
|
||||
// Setup device
|
||||
this._device = device;
|
||||
this._device.keepValues = false; // todo: possibly remove after updating Vernier godirect module
|
||||
|
||||
// Enable sensors
|
||||
this._device.sensors.forEach(sensor => {
|
||||
sensor.setEnabled(true);
|
||||
});
|
||||
|
||||
// Set sensor value-update behavior
|
||||
this._device.on('measurements-started', () => {
|
||||
const enabledSensors = this._device.sensors.filter(s => s.enabled);
|
||||
enabledSensors.forEach(sensor => {
|
||||
sensor.on('value-changed', s => {
|
||||
this._onSensorValueChanged(s);
|
||||
});
|
||||
});
|
||||
this._timeoutID = window.setInterval(
|
||||
() => this._ble.handleDisconnectError(BLEDataStoppedError),
|
||||
BLETimeout
|
||||
);
|
||||
});
|
||||
|
||||
// Start device
|
||||
this._device.start(GDXFOR_UPDATE_RATE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for sensor value changes from the goforce device.
|
||||
* @param {object} sensor - goforce device sensor whose value has changed
|
||||
* @private
|
||||
*/
|
||||
_onSensorValueChanged (sensor) {
|
||||
switch (sensor.number) {
|
||||
case GDXFOR_SENSOR.FORCE:
|
||||
// Normalize the force, which can be measured between -50 and 50 N,
|
||||
// to be a value between -100 and 100.
|
||||
this._sensors.force = MathUtil.clamp(sensor.value * 2, -100, 100);
|
||||
break;
|
||||
case GDXFOR_SENSOR.ACCELERATION_X:
|
||||
this._sensors.accelerationX = sensor.value;
|
||||
break;
|
||||
case GDXFOR_SENSOR.ACCELERATION_Y:
|
||||
this._sensors.accelerationY = sensor.value;
|
||||
break;
|
||||
case GDXFOR_SENSOR.ACCELERATION_Z:
|
||||
this._sensors.accelerationZ = sensor.value;
|
||||
break;
|
||||
case GDXFOR_SENSOR.SPIN_SPEED_X:
|
||||
this._sensors.spinSpeedX = this._spinSpeedFromGyro(sensor.value);
|
||||
break;
|
||||
case GDXFOR_SENSOR.SPIN_SPEED_Y:
|
||||
this._sensors.spinSpeedY = this._spinSpeedFromGyro(sensor.value);
|
||||
break;
|
||||
case GDXFOR_SENSOR.SPIN_SPEED_Z:
|
||||
this._sensors.spinSpeedZ = this._spinSpeedFromGyro(sensor.value);
|
||||
break;
|
||||
}
|
||||
// cancel disconnect timeout and start a new one
|
||||
window.clearInterval(this._timeoutID);
|
||||
this._timeoutID = window.setInterval(
|
||||
() => this._ble.handleDisconnectError(BLEDataStoppedError),
|
||||
BLETimeout
|
||||
);
|
||||
}
|
||||
|
||||
_spinSpeedFromGyro (val) {
|
||||
const framesPerSec = 1000 / this._runtime.currentStepTime;
|
||||
val = MathUtil.radToDeg(val);
|
||||
val = val / framesPerSec; // convert to from degrees per sec to degrees per frame
|
||||
val = val * -1;
|
||||
return val;
|
||||
}
|
||||
|
||||
getForce () {
|
||||
return this._sensors.force;
|
||||
}
|
||||
|
||||
getTiltFrontBack (back = false) {
|
||||
const x = this.getAccelerationX();
|
||||
const y = this.getAccelerationY();
|
||||
const z = this.getAccelerationZ();
|
||||
|
||||
// Compute the yz unit vector
|
||||
const y2 = y * y;
|
||||
const z2 = z * z;
|
||||
let value = y2 + z2;
|
||||
value = Math.sqrt(value);
|
||||
|
||||
// For sufficiently small zy vector values we are essentially at 90 degrees.
|
||||
// The following snaps to 90 and avoids divide-by-zero errors.
|
||||
// The snap factor was derived through observation -- just enough to
|
||||
// still allow single degree steps up to 90 (..., 87, 88, 89, 90).
|
||||
if (value < 0.35) {
|
||||
value = (x < 0) ? 90 : -90;
|
||||
} else {
|
||||
value = x / value;
|
||||
value = Math.atan(value);
|
||||
value = MathUtil.radToDeg(value) * -1;
|
||||
}
|
||||
|
||||
// Back is the inverse of front
|
||||
if (back) value *= -1;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getTiltLeftRight (right = false) {
|
||||
const x = this.getAccelerationX();
|
||||
const y = this.getAccelerationY();
|
||||
const z = this.getAccelerationZ();
|
||||
|
||||
// Compute the yz unit vector
|
||||
const x2 = x * x;
|
||||
const z2 = z * z;
|
||||
let value = x2 + z2;
|
||||
value = Math.sqrt(value);
|
||||
|
||||
// For sufficiently small zy vector values we are essentially at 90 degrees.
|
||||
// The following snaps to 90 and avoids divide-by-zero errors.
|
||||
// The snap factor was derived through observation -- just enough to
|
||||
// still allow single degree steps up to 90 (..., 87, 88, 89, 90).
|
||||
if (value < 0.35) {
|
||||
value = (y < 0) ? 90 : -90;
|
||||
} else {
|
||||
value = y / value;
|
||||
value = Math.atan(value);
|
||||
value = MathUtil.radToDeg(value) * -1;
|
||||
}
|
||||
|
||||
// Right is the inverse of left
|
||||
if (right) value *= -1;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getAccelerationX () {
|
||||
return this._sensors.accelerationX;
|
||||
}
|
||||
|
||||
getAccelerationY () {
|
||||
return this._sensors.accelerationY;
|
||||
}
|
||||
|
||||
getAccelerationZ () {
|
||||
return this._sensors.accelerationZ;
|
||||
}
|
||||
|
||||
getSpinSpeedX () {
|
||||
return this._sensors.spinSpeedX;
|
||||
}
|
||||
|
||||
getSpinSpeedY () {
|
||||
return this._sensors.spinSpeedY;
|
||||
}
|
||||
|
||||
getSpinSpeedZ () {
|
||||
return this._sensors.spinSpeedZ;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for pushed and pulled menu options.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const PushPullValues = {
|
||||
PUSHED: 'pushed',
|
||||
PULLED: 'pulled'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for motion gesture menu options.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const GestureValues = {
|
||||
SHAKEN: 'shaken',
|
||||
STARTED_FALLING: 'started falling',
|
||||
TURNED_FACE_UP: 'turned face up',
|
||||
TURNED_FACE_DOWN: 'turned face down'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for tilt axis menu options.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const TiltAxisValues = {
|
||||
FRONT: 'front',
|
||||
BACK: 'back',
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
ANY: 'any'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for axis menu options.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const AxisValues = {
|
||||
X: 'x',
|
||||
Y: 'y',
|
||||
Z: 'z'
|
||||
};
|
||||
|
||||
/**
|
||||
* Scratch 3.0 blocks to interact with a GDX-FOR peripheral.
|
||||
*/
|
||||
class Scratch3GdxForBlocks {
|
||||
|
||||
/**
|
||||
* @return {string} - the name of this extension.
|
||||
*/
|
||||
static get EXTENSION_NAME () {
|
||||
return 'Force and Acceleration';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string} - the ID of this extension.
|
||||
*/
|
||||
static get EXTENSION_ID () {
|
||||
return 'gdxfor';
|
||||
}
|
||||
|
||||
get AXIS_MENU () {
|
||||
return [
|
||||
{
|
||||
text: 'x',
|
||||
value: AxisValues.X
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
value: AxisValues.Y
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
value: AxisValues.Z
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get TILT_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.tiltDirectionMenu.front',
|
||||
default: 'front',
|
||||
description: 'label for front element in tilt direction picker for gdxfor extension'
|
||||
}),
|
||||
value: TiltAxisValues.FRONT
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.tiltDirectionMenu.back',
|
||||
default: 'back',
|
||||
description: 'label for back element in tilt direction picker for gdxfor extension'
|
||||
}),
|
||||
value: TiltAxisValues.BACK
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.tiltDirectionMenu.left',
|
||||
default: 'left',
|
||||
description: 'label for left element in tilt direction picker for gdxfor extension'
|
||||
}),
|
||||
value: TiltAxisValues.LEFT
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.tiltDirectionMenu.right',
|
||||
default: 'right',
|
||||
description: 'label for right element in tilt direction picker for gdxfor extension'
|
||||
}),
|
||||
value: TiltAxisValues.RIGHT
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get TILT_MENU_ANY () {
|
||||
return [
|
||||
...this.TILT_MENU,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.tiltDirectionMenu.any',
|
||||
default: 'any',
|
||||
description: 'label for any direction element in tilt direction picker for gdxfor extension'
|
||||
}),
|
||||
value: TiltAxisValues.ANY
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get PUSH_PULL_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.pushed',
|
||||
default: 'pushed',
|
||||
description: 'the force sensor was pushed inward'
|
||||
}),
|
||||
value: PushPullValues.PUSHED
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.pulled',
|
||||
default: 'pulled',
|
||||
description: 'the force sensor was pulled outward'
|
||||
}),
|
||||
value: PushPullValues.PULLED
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get GESTURE_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.shaken',
|
||||
default: 'shaken',
|
||||
description: 'the sensor was shaken'
|
||||
}),
|
||||
value: GestureValues.SHAKEN
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.startedFalling',
|
||||
default: 'started falling',
|
||||
description: 'the sensor started free falling'
|
||||
}),
|
||||
value: GestureValues.STARTED_FALLING
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.turnedFaceUp',
|
||||
default: 'turned face up',
|
||||
description: 'the sensor was turned to face up'
|
||||
}),
|
||||
value: GestureValues.TURNED_FACE_UP
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.turnedFaceDown',
|
||||
default: 'turned face down',
|
||||
description: 'the sensor was turned to face down'
|
||||
}),
|
||||
value: GestureValues.TURNED_FACE_DOWN
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a set of GDX-FOR blocks.
|
||||
* @param {Runtime} runtime - the Scratch 3.0 runtime.
|
||||
*/
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The Scratch 3.0 runtime.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
// Create a new GdxFor peripheral instance
|
||||
this._peripheral = new GdxFor(this.runtime, Scratch3GdxForBlocks.EXTENSION_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} metadata for this extension and its blocks.
|
||||
*/
|
||||
getInfo () {
|
||||
return {
|
||||
id: Scratch3GdxForBlocks.EXTENSION_ID,
|
||||
name: Scratch3GdxForBlocks.EXTENSION_NAME,
|
||||
blockIconURI: blockIconURI,
|
||||
menuIconURI: menuIconURI,
|
||||
showStatusButton: true,
|
||||
blocks: [
|
||||
{
|
||||
opcode: 'whenGesture',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.whenGesture',
|
||||
default: 'when [GESTURE]',
|
||||
description: 'when the sensor detects a gesture'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
GESTURE: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'gestureOptions',
|
||||
defaultValue: GestureValues.SHAKEN
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'whenForcePushedOrPulled',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.whenForcePushedOrPulled',
|
||||
default: 'when force sensor [PUSH_PULL]',
|
||||
description: 'when the force sensor is pushed or pulled'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
PUSH_PULL: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'pushPullOptions',
|
||||
defaultValue: PushPullValues.PUSHED
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'getForce',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.getForce',
|
||||
default: 'force',
|
||||
description: 'gets force'
|
||||
}),
|
||||
blockType: BlockType.REPORTER
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'whenTilted',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.whenTilted',
|
||||
default: 'when tilted [TILT]',
|
||||
description: 'when the sensor detects tilt'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
TILT: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltAnyOptions',
|
||||
defaultValue: TiltAxisValues.ANY
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'isTilted',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.isTilted',
|
||||
default: 'tilted [TILT]?',
|
||||
description: 'is the device tilted?'
|
||||
}),
|
||||
blockType: BlockType.BOOLEAN,
|
||||
arguments: {
|
||||
TILT: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltAnyOptions',
|
||||
defaultValue: TiltAxisValues.ANY
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'getTilt',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.getTilt',
|
||||
default: 'tilt angle [TILT]',
|
||||
description: 'gets tilt'
|
||||
}),
|
||||
blockType: BlockType.REPORTER,
|
||||
arguments: {
|
||||
TILT: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltOptions',
|
||||
defaultValue: TiltAxisValues.FRONT
|
||||
}
|
||||
}
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'isFreeFalling',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.isFreeFalling',
|
||||
default: 'falling?',
|
||||
description: 'is the device in free fall?'
|
||||
}),
|
||||
blockType: BlockType.BOOLEAN
|
||||
},
|
||||
{
|
||||
opcode: 'getSpinSpeed',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.getSpin',
|
||||
default: 'spin speed [DIRECTION]',
|
||||
description: 'gets spin speed'
|
||||
}),
|
||||
blockType: BlockType.REPORTER,
|
||||
arguments: {
|
||||
DIRECTION: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'axisOptions',
|
||||
defaultValue: AxisValues.Z
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'getAcceleration',
|
||||
text: formatMessage({
|
||||
id: 'gdxfor.getAcceleration',
|
||||
default: 'acceleration [DIRECTION]',
|
||||
description: 'gets acceleration'
|
||||
}),
|
||||
blockType: BlockType.REPORTER,
|
||||
arguments: {
|
||||
DIRECTION: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'axisOptions',
|
||||
defaultValue: AxisValues.X
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
menus: {
|
||||
pushPullOptions: {
|
||||
acceptReporters: true,
|
||||
items: this.PUSH_PULL_MENU
|
||||
},
|
||||
gestureOptions: {
|
||||
acceptReporters: true,
|
||||
items: this.GESTURE_MENU
|
||||
},
|
||||
axisOptions: {
|
||||
acceptReporters: true,
|
||||
items: this.AXIS_MENU
|
||||
},
|
||||
tiltOptions: {
|
||||
acceptReporters: true,
|
||||
items: this.TILT_MENU
|
||||
},
|
||||
tiltAnyOptions: {
|
||||
acceptReporters: true,
|
||||
items: this.TILT_MENU_ANY
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
whenForcePushedOrPulled (args) {
|
||||
switch (args.PUSH_PULL) {
|
||||
case PushPullValues.PUSHED:
|
||||
return this._peripheral.getForce() < FORCE_THRESHOLD * -1;
|
||||
case PushPullValues.PULLED:
|
||||
return this._peripheral.getForce() > FORCE_THRESHOLD;
|
||||
default:
|
||||
log.warn(`unknown push/pull value in whenForcePushedOrPulled: ${args.PUSH_PULL}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getForce () {
|
||||
return Math.round(this._peripheral.getForce());
|
||||
}
|
||||
|
||||
whenGesture (args) {
|
||||
switch (args.GESTURE) {
|
||||
case GestureValues.SHAKEN:
|
||||
return this.gestureMagnitude() > SHAKEN_THRESHOLD;
|
||||
case GestureValues.STARTED_FALLING:
|
||||
return this.isFreeFalling();
|
||||
case GestureValues.TURNED_FACE_UP:
|
||||
return this._isFacing(GestureValues.TURNED_FACE_UP);
|
||||
case GestureValues.TURNED_FACE_DOWN:
|
||||
return this._isFacing(GestureValues.TURNED_FACE_DOWN);
|
||||
default:
|
||||
log.warn(`unknown gesture value in whenGesture: ${args.GESTURE}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_isFacing (direction) {
|
||||
if (typeof this._facingUp === 'undefined') {
|
||||
this._facingUp = false;
|
||||
}
|
||||
if (typeof this._facingDown === 'undefined') {
|
||||
this._facingDown = false;
|
||||
}
|
||||
|
||||
// If the sensor is already facing up or down, reduce the threshold.
|
||||
// This prevents small fluctations in acceleration while it is being
|
||||
// turned from causing the hat block to trigger multiple times.
|
||||
let threshold = FACING_THRESHOLD;
|
||||
if (this._facingUp || this._facingDown) {
|
||||
threshold -= FACING_THRESHOLD_OFFSET;
|
||||
}
|
||||
|
||||
this._facingUp = this._peripheral.getAccelerationZ() > threshold;
|
||||
this._facingDown = this._peripheral.getAccelerationZ() < threshold * -1;
|
||||
|
||||
switch (direction) {
|
||||
case GestureValues.TURNED_FACE_UP:
|
||||
return this._facingUp;
|
||||
case GestureValues.TURNED_FACE_DOWN:
|
||||
return this._facingDown;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
whenTilted (args) {
|
||||
return this._isTilted(args.TILT);
|
||||
}
|
||||
|
||||
isTilted (args) {
|
||||
return this._isTilted(args.TILT);
|
||||
}
|
||||
|
||||
getTilt (args) {
|
||||
return this._getTiltAngle(args.TILT);
|
||||
}
|
||||
|
||||
_isTilted (direction) {
|
||||
switch (direction) {
|
||||
case TiltAxisValues.ANY:
|
||||
return this._getTiltAngle(TiltAxisValues.FRONT) > TILT_THRESHOLD ||
|
||||
this._getTiltAngle(TiltAxisValues.BACK) > TILT_THRESHOLD ||
|
||||
this._getTiltAngle(TiltAxisValues.LEFT) > TILT_THRESHOLD ||
|
||||
this._getTiltAngle(TiltAxisValues.RIGHT) > TILT_THRESHOLD;
|
||||
default:
|
||||
return this._getTiltAngle(direction) > TILT_THRESHOLD;
|
||||
}
|
||||
}
|
||||
|
||||
_getTiltAngle (direction) {
|
||||
// Tilt values are calculated using acceleration due to gravity,
|
||||
// so we need to return 0 when the peripheral is not connected.
|
||||
if (!this._peripheral.isConnected()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (direction) {
|
||||
case TiltAxisValues.FRONT:
|
||||
return Math.round(this._peripheral.getTiltFrontBack(true));
|
||||
case TiltAxisValues.BACK:
|
||||
return Math.round(this._peripheral.getTiltFrontBack(false));
|
||||
case TiltAxisValues.LEFT:
|
||||
return Math.round(this._peripheral.getTiltLeftRight(true));
|
||||
case TiltAxisValues.RIGHT:
|
||||
return Math.round(this._peripheral.getTiltLeftRight(false));
|
||||
default:
|
||||
log.warn(`Unknown direction in getTilt: ${direction}`);
|
||||
}
|
||||
}
|
||||
|
||||
getSpinSpeed (args) {
|
||||
switch (args.DIRECTION) {
|
||||
case AxisValues.X:
|
||||
return Math.round(this._peripheral.getSpinSpeedX());
|
||||
case AxisValues.Y:
|
||||
return Math.round(this._peripheral.getSpinSpeedY());
|
||||
case AxisValues.Z:
|
||||
return Math.round(this._peripheral.getSpinSpeedZ());
|
||||
default:
|
||||
log.warn(`Unknown direction in getSpinSpeed: ${args.DIRECTION}`);
|
||||
}
|
||||
}
|
||||
|
||||
getAcceleration (args) {
|
||||
switch (args.DIRECTION) {
|
||||
case AxisValues.X:
|
||||
return Math.round(this._peripheral.getAccelerationX());
|
||||
case AxisValues.Y:
|
||||
return Math.round(this._peripheral.getAccelerationY());
|
||||
case AxisValues.Z:
|
||||
return Math.round(this._peripheral.getAccelerationZ());
|
||||
default:
|
||||
log.warn(`Unknown direction in getAcceleration: ${args.DIRECTION}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x - x axis vector
|
||||
* @param {number} y - y axis vector
|
||||
* @param {number} z - z axis vector
|
||||
* @return {number} - the magnitude of a three dimension vector.
|
||||
*/
|
||||
magnitude (x, y, z) {
|
||||
return Math.sqrt((x * x) + (y * y) + (z * z));
|
||||
}
|
||||
|
||||
accelMagnitude () {
|
||||
return this.magnitude(
|
||||
this._peripheral.getAccelerationX(),
|
||||
this._peripheral.getAccelerationY(),
|
||||
this._peripheral.getAccelerationZ()
|
||||
);
|
||||
}
|
||||
|
||||
gestureMagnitude () {
|
||||
return this.accelMagnitude() - GRAVITY;
|
||||
}
|
||||
|
||||
spinMagnitude () {
|
||||
return this.magnitude(
|
||||
this._peripheral.getSpinSpeedX(),
|
||||
this._peripheral.getSpinSpeedY(),
|
||||
this._peripheral.getSpinSpeedZ()
|
||||
);
|
||||
}
|
||||
|
||||
isFreeFalling () {
|
||||
// When the peripheral is not connected, the acceleration magnitude
|
||||
// is 0 instead of ~9.8, which ends up calculating as a positive
|
||||
// free fall; so we need to return 'false' here to prevent returning 'true'.
|
||||
if (!this._peripheral.isConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accelMag = this.accelMagnitude();
|
||||
const spinMag = this.spinMagnitude();
|
||||
|
||||
// We want to account for rotation during freefall,
|
||||
// so we tack on a an estimated "rotational effect"
|
||||
// The FREEFALL_ROTATION_FACTOR const is used to both scale the
|
||||
// gyro measurements and convert them to radians/second.
|
||||
// So, we compare our accel magnitude against:
|
||||
// FREEFALL_THRESHOLD + (some_scaled_magnitude_of_rotation).
|
||||
const ffThresh = FREEFALL_THRESHOLD + (FREEFALL_ROTATION_FACTOR * spinMag);
|
||||
|
||||
return accelMag < ffThresh;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3GdxForBlocks;
|
||||
@@ -0,0 +1,44 @@
|
||||
const Base64Util = require('../../util/base64-util');
|
||||
|
||||
/**
|
||||
* Adapter class
|
||||
*/
|
||||
class ScratchLinkDeviceAdapter {
|
||||
constructor (socket, {service, commandChar, responseChar}) {
|
||||
this.socket = socket;
|
||||
|
||||
this._service = service;
|
||||
this._commandChar = commandChar;
|
||||
this._responseChar = responseChar;
|
||||
this._onResponse = this._onResponse.bind(this);
|
||||
this._deviceOnResponse = null;
|
||||
}
|
||||
|
||||
get godirectAdapter () {
|
||||
return true;
|
||||
}
|
||||
|
||||
writeCommand (commandBuffer) {
|
||||
const data = Base64Util.uint8ArrayToBase64(commandBuffer);
|
||||
|
||||
return this.socket
|
||||
.write(this._service, this._commandChar, data, 'base64');
|
||||
}
|
||||
|
||||
setup ({onResponse}) {
|
||||
this._deviceOnResponse = onResponse;
|
||||
return this.socket
|
||||
.startNotifications(this._service, this._responseChar, this._onResponse);
|
||||
|
||||
// TODO:
|
||||
// How do we find out from scratch link if communication closes?
|
||||
}
|
||||
|
||||
_onResponse (base64) {
|
||||
const array = Base64Util.base64ToUint8Array(base64);
|
||||
const response = new DataView(array.buffer);
|
||||
return this._deviceOnResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScratchLinkDeviceAdapter;
|
||||
396
scratch-vm/src/extensions/scratch3_makeymakey/index.js
Normal file
396
scratch-vm/src/extensions/scratch3_makeymakey/index.js
Normal file
@@ -0,0 +1,396 @@
|
||||
const formatMessage = require('format-message');
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
const BlockType = require('../../extension-support/block-type');
|
||||
const Cast = require('../../util/cast');
|
||||
|
||||
/**
|
||||
* 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,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHN0eWxlPi5zdDJ7ZmlsbDpyZWR9LnN0M3tmaWxsOiNlMGUwZTB9LnN0NHtmaWxsOm5vbmU7c3Ryb2tlOiM2NjY7c3Ryb2tlLXdpZHRoOi41O3N0cm9rZS1taXRlcmxpbWl0OjEwfTwvc3R5bGU+PHBhdGggZD0iTTM1IDI4SDVhMSAxIDAgMCAxLTEtMVYxMmMwLS42LjQtMSAxLTFoMzBjLjUgMCAxIC40IDEgMXYxNWMwIC41LS41IDEtMSAxeiIgZmlsbD0iI2ZmZiIgaWQ9IkxheWVyXzYiLz48ZyBpZD0iTGF5ZXJfNCI+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQgMjVoMzJ2Mi43SDR6TTEzIDI0aC0yLjJhMSAxIDAgMCAxLTEtMXYtOS43YzAtLjYuNC0xIDEtMUgxM2MuNiAwIDEgLjQgMSAxVjIzYzAgLjYtLjUgMS0xIDF6Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTYuMSAxOS4zdi0yLjJjMC0uNS40LTEgMS0xaDkuN2MuNSAwIDEgLjUgMSAxdjIuMmMwIC41LS41IDEtMSAxSDcuMWExIDEgMCAwIDEtMS0xeiIvPjxjaXJjbGUgY2xhc3M9InN0MiIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIzLjQiLz48Y2lyY2xlIGNsYXNzPSJzdDIiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMy40Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQuMiAyN2gzMS45di43SDQuMnoiLz48L2c+PGcgaWQ9IkxheWVyXzUiPjxjaXJjbGUgY2xhc3M9InN0MyIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIyLjMiLz48Y2lyY2xlIGNsYXNzPSJzdDMiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMi4zIi8+PHBhdGggY2xhc3M9InN0MyIgZD0iTTEyLjUgMjIuOWgtMS4yYy0uMyAwLS41LS4yLS41LS41VjE0YzAtLjMuMi0uNS41LS41aDEuMmMuMyAwIC41LjIuNS41djguNGMwIC4zLS4yLjUtLjUuNXoiLz48cGF0aCBjbGFzcz0ic3QzIiBkPSJNNy4yIDE4Ljd2LTEuMmMwLS4zLjItLjUuNS0uNWg4LjRjLjMgMCAuNS4yLjUuNXYxLjJjMCAuMy0uMi41LS41LjVINy43Yy0uMyAwLS41LS4yLS41LS41ek00IDI2aDMydjJINHoiLz48L2c+PGcgaWQ9IkxheWVyXzMiPjxwYXRoIGNsYXNzPSJzdDQiIGQ9Ik0zNS4yIDI3LjlINC44YTEgMSAwIDAgMS0xLTFWMTIuMWMwLS42LjUtMSAxLTFoMzAuNWMuNSAwIDEgLjQgMSAxVjI3YTEgMSAwIDAgMS0xLjEuOXoiLz48cGF0aCBjbGFzcz0ic3Q0IiBkPSJNMzUuMiAyNy45SDQuOGExIDEgMCAwIDEtMS0xVjEyLjFjMC0uNi41LTEgMS0xaDMwLjVjLjUgMCAxIC40IDEgMVYyN2ExIDEgMCAwIDEtMS4xLjl6Ii8+PC9nPjwvc3ZnPg==';
|
||||
|
||||
/**
|
||||
* Length of the buffer to store key presses for the "when keys pressed in order" hat
|
||||
* @type {number}
|
||||
*/
|
||||
const KEY_BUFFER_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Timeout in milliseconds to reset the completed flag for a sequence.
|
||||
* @type {number}
|
||||
*/
|
||||
const SEQUENCE_HAT_TIMEOUT = 100;
|
||||
|
||||
/**
|
||||
* An id for the space key on a keyboard.
|
||||
*/
|
||||
const KEY_ID_SPACE = 'SPACE';
|
||||
|
||||
/**
|
||||
* An id for the left arrow key on a keyboard.
|
||||
*/
|
||||
const KEY_ID_LEFT = 'LEFT';
|
||||
|
||||
/**
|
||||
* An id for the right arrow key on a keyboard.
|
||||
*/
|
||||
const KEY_ID_RIGHT = 'RIGHT';
|
||||
|
||||
/**
|
||||
* An id for the up arrow key on a keyboard.
|
||||
*/
|
||||
const KEY_ID_UP = 'UP';
|
||||
|
||||
/**
|
||||
* An id for the down arrow key on a keyboard.
|
||||
*/
|
||||
const KEY_ID_DOWN = 'DOWN';
|
||||
|
||||
/**
|
||||
* Names used by keyboard io for keys used in scratch.
|
||||
* @enum {string}
|
||||
*/
|
||||
const SCRATCH_KEY_NAME = {
|
||||
[KEY_ID_SPACE]: 'space',
|
||||
[KEY_ID_LEFT]: 'left arrow',
|
||||
[KEY_ID_UP]: 'up arrow',
|
||||
[KEY_ID_RIGHT]: 'right arrow',
|
||||
[KEY_ID_DOWN]: 'down arrow'
|
||||
};
|
||||
|
||||
/**
|
||||
* Class for the makey makey blocks in Scratch 3.0
|
||||
* @constructor
|
||||
*/
|
||||
class Scratch3MakeyMakeyBlocks {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The runtime instantiating this block package.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
/**
|
||||
* A toggle that alternates true and false each frame, so that an
|
||||
* edge-triggered hat can trigger on every other frame.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.frameToggle = false;
|
||||
|
||||
// Set an interval that toggles the frameToggle every frame.
|
||||
setInterval(() => {
|
||||
this.frameToggle = !this.frameToggle;
|
||||
}, this.runtime.currentStepTime);
|
||||
|
||||
this.keyPressed = this.keyPressed.bind(this);
|
||||
this.runtime.on('KEY_PRESSED', this.keyPressed);
|
||||
|
||||
this._clearkeyPressBuffer = this._clearkeyPressBuffer.bind(this);
|
||||
this.runtime.on('PROJECT_STOP_ALL', this._clearkeyPressBuffer);
|
||||
|
||||
/*
|
||||
* An object containing a set of sequence objects.
|
||||
* These are the key sequences currently being detected by the "when
|
||||
* keys pressed in order" hat block. Each sequence is keyed by its
|
||||
* string representation (the sequence's value in the menu, which is a
|
||||
* string of KEY_IDs separated by spaces). Each sequence object
|
||||
* has an array property (an array of KEY_IDs) and a boolean
|
||||
* completed property that is true when the sequence has just been
|
||||
* pressed.
|
||||
* @type {object}
|
||||
*/
|
||||
this.sequences = {};
|
||||
|
||||
/*
|
||||
* An array of the key codes of recently pressed keys.
|
||||
* @type {array}
|
||||
*/
|
||||
this.keyPressBuffer = [];
|
||||
}
|
||||
|
||||
/*
|
||||
* Localized short-form names of the space bar and arrow keys, for use in the
|
||||
* displayed menu items of the "when keys pressed in order" block.
|
||||
* @type {object}
|
||||
*/
|
||||
get KEY_TEXT_SHORT () {
|
||||
return {
|
||||
[KEY_ID_SPACE]: formatMessage({
|
||||
id: 'makeymakey.spaceKey',
|
||||
default: 'space',
|
||||
description: 'The space key on a computer keyboard.'
|
||||
}),
|
||||
[KEY_ID_LEFT]: formatMessage({
|
||||
id: 'makeymakey.leftArrowShort',
|
||||
default: 'left',
|
||||
description: 'Short name for the left arrow key on a computer keyboard.'
|
||||
}),
|
||||
[KEY_ID_UP]: formatMessage({
|
||||
id: 'makeymakey.upArrowShort',
|
||||
default: 'up',
|
||||
description: 'Short name for the up arrow key on a computer keyboard.'
|
||||
}),
|
||||
[KEY_ID_RIGHT]: formatMessage({
|
||||
id: 'makeymakey.rightArrowShort',
|
||||
default: 'right',
|
||||
description: 'Short name for the right arrow key on a computer keyboard.'
|
||||
}),
|
||||
[KEY_ID_DOWN]: formatMessage({
|
||||
id: 'makeymakey.downArrowShort',
|
||||
default: 'down',
|
||||
description: 'Short name for the down arrow key on a computer keyboard.'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* An array of strings of KEY_IDs representing the default set of
|
||||
* key sequences for use by the "when keys pressed in order" block.
|
||||
* @type {array}
|
||||
*/
|
||||
get DEFAULT_SEQUENCES () {
|
||||
return [
|
||||
`${KEY_ID_LEFT} ${KEY_ID_UP} ${KEY_ID_RIGHT}`,
|
||||
`${KEY_ID_RIGHT} ${KEY_ID_UP} ${KEY_ID_LEFT}`,
|
||||
`${KEY_ID_LEFT} ${KEY_ID_RIGHT}`,
|
||||
`${KEY_ID_RIGHT} ${KEY_ID_LEFT}`,
|
||||
`${KEY_ID_UP} ${KEY_ID_DOWN}`,
|
||||
`${KEY_ID_DOWN} ${KEY_ID_UP}`,
|
||||
`${KEY_ID_UP} ${KEY_ID_RIGHT} ${KEY_ID_DOWN} ${KEY_ID_LEFT}`,
|
||||
`${KEY_ID_UP} ${KEY_ID_LEFT} ${KEY_ID_DOWN} ${KEY_ID_RIGHT}`,
|
||||
`${KEY_ID_UP} ${KEY_ID_UP} ${KEY_ID_DOWN} ${KEY_ID_DOWN} ` +
|
||||
`${KEY_ID_LEFT} ${KEY_ID_RIGHT} ${KEY_ID_LEFT} ${KEY_ID_RIGHT}`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} metadata for this extension and its blocks.
|
||||
*/
|
||||
getInfo () {
|
||||
return {
|
||||
id: 'makeymakey',
|
||||
name: 'Makey Makey',
|
||||
blockIconURI: blockIconURI,
|
||||
blocks: [
|
||||
{
|
||||
opcode: 'whenMakeyKeyPressed',
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.whenKeyPressed',
|
||||
default: 'when [KEY] key pressed',
|
||||
description: 'when a keyboard key is pressed'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
KEY: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'KEY',
|
||||
defaultValue: KEY_ID_SPACE
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'whenCodePressed',
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.whenKeysPressedInOrder',
|
||||
default: 'when [SEQUENCE] pressed in order',
|
||||
description: 'when a sequence of keyboard keys is pressed in a specific order'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
SEQUENCE: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'SEQUENCE',
|
||||
defaultValue: this.DEFAULT_SEQUENCES[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
menus: {
|
||||
KEY: {
|
||||
acceptReporters: true,
|
||||
items: [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.spaceKey',
|
||||
default: 'space',
|
||||
description: 'The space key on a computer keyboard.'
|
||||
}),
|
||||
value: KEY_ID_SPACE
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.upArrow',
|
||||
default: 'up arrow',
|
||||
description: 'The up arrow key on a computer keyboard.'
|
||||
}),
|
||||
value: KEY_ID_UP
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.downArrow',
|
||||
default: 'down arrow',
|
||||
description: 'The down arrow key on a computer keyboard.'
|
||||
}),
|
||||
value: KEY_ID_DOWN
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.rightArrow',
|
||||
default: 'right arrow',
|
||||
description: 'The right arrow key on a computer keyboard.'
|
||||
}),
|
||||
value: KEY_ID_RIGHT
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'makeymakey.leftArrow',
|
||||
default: 'left arrow',
|
||||
description: 'The left arrow key on a computer keyboard.'
|
||||
}),
|
||||
value: KEY_ID_LEFT
|
||||
},
|
||||
{text: 'w', value: 'w'},
|
||||
{text: 'a', value: 'a'},
|
||||
{text: 's', value: 's'},
|
||||
{text: 'd', value: 'd'},
|
||||
{text: 'f', value: 'f'},
|
||||
{text: 'g', value: 'g'}
|
||||
]
|
||||
},
|
||||
SEQUENCE: {
|
||||
acceptReporters: true,
|
||||
items: this.buildSequenceMenu(this.DEFAULT_SEQUENCES)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Build the menu of key sequences.
|
||||
* @param {array} sequencesArray an array of strings of KEY_IDs.
|
||||
* @returns {array} an array of objects with text and value properties.
|
||||
*/
|
||||
buildSequenceMenu (sequencesArray) {
|
||||
return sequencesArray.map(
|
||||
str => this.getMenuItemForSequenceString(str)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Create a menu item for a sequence string.
|
||||
* @param {string} sequenceString a string of KEY_IDs.
|
||||
* @return {object} an object with text and value properties.
|
||||
*/
|
||||
getMenuItemForSequenceString (sequenceString) {
|
||||
let sequenceArray = sequenceString.split(' ');
|
||||
sequenceArray = sequenceArray.map(str => this.KEY_TEXT_SHORT[str]);
|
||||
return {
|
||||
text: sequenceArray.join(' '),
|
||||
value: sequenceString
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a keyboard key is currently pressed.
|
||||
* Also, toggle the results of the test on alternate frames, so that the
|
||||
* hat block fires repeatedly.
|
||||
* @param {object} args - the block arguments.
|
||||
* @property {number} KEY - a key code.
|
||||
* @param {object} util - utility object provided by the runtime.
|
||||
*/
|
||||
whenMakeyKeyPressed (args, util) {
|
||||
let key = args.KEY;
|
||||
// Convert the key arg, if it is a KEY_ID, to the key name used by
|
||||
// the Keyboard io module.
|
||||
if (SCRATCH_KEY_NAME[args.KEY]) {
|
||||
key = SCRATCH_KEY_NAME[args.KEY];
|
||||
}
|
||||
const isDown = util.ioQuery('keyboard', 'getKeyIsDown', [key]);
|
||||
return (isDown && this.frameToggle);
|
||||
}
|
||||
|
||||
/*
|
||||
* A function called on the KEY_PRESSED event, to update the key press
|
||||
* buffer and check if any of the key sequences have been completed.
|
||||
* @param {string} key A scratch key name.
|
||||
*/
|
||||
keyPressed (key) {
|
||||
// Store only the first word of the Scratch key name, so that e.g. when
|
||||
// "left arrow" is pressed, we store "LEFT", which matches KEY_ID_LEFT
|
||||
key = key.split(' ')[0];
|
||||
key = key.toUpperCase();
|
||||
this.keyPressBuffer.push(key);
|
||||
// Keep the buffer under the length limit
|
||||
if (this.keyPressBuffer.length > KEY_BUFFER_LENGTH) {
|
||||
this.keyPressBuffer.shift();
|
||||
}
|
||||
// Check the buffer for each sequence in use
|
||||
for (const str in this.sequences) {
|
||||
const arr = this.sequences[str].array;
|
||||
// Bail out if we don't have enough presses for this sequence
|
||||
if (this.keyPressBuffer.length < arr.length) {
|
||||
continue;
|
||||
}
|
||||
let missFlag = false;
|
||||
// Slice the buffer to the length of the sequence we're checking
|
||||
const bufferSegment = this.keyPressBuffer.slice(-1 * arr.length);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] !== bufferSegment[i]) {
|
||||
missFlag = true;
|
||||
}
|
||||
}
|
||||
// If the miss flag is false, the sequence matched the buffer
|
||||
if (!missFlag) {
|
||||
this.sequences[str].completed = true;
|
||||
// Clear the completed flag after a timeout. This is necessary because
|
||||
// the hat is edge-triggered (not event triggered). Multiple hats
|
||||
// may be checking the same sequence, so this timeout gives them enough
|
||||
// time to all trigger before resetting the flag.
|
||||
setTimeout(() => {
|
||||
this.sequences[str].completed = false;
|
||||
}, SEQUENCE_HAT_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the key press buffer.
|
||||
*/
|
||||
_clearkeyPressBuffer () {
|
||||
this.keyPressBuffer = [];
|
||||
}
|
||||
|
||||
/*
|
||||
* Add a key sequence to the set currently being checked on each key press.
|
||||
* @param {string} sequenceString a string of space-separated KEY_IDs.
|
||||
* @param {array} sequenceArray an array of KEY_IDs.
|
||||
*/
|
||||
addSequence (sequenceString, sequenceArray) {
|
||||
// If we already have this sequence string, return.
|
||||
if (Object.prototype.hasOwnProperty.call(this.sequences, sequenceString)) {
|
||||
return;
|
||||
}
|
||||
this.sequences[sequenceString] = {
|
||||
array: sequenceArray,
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a key sequence was recently completed.
|
||||
* @param {object} args The block arguments.
|
||||
* @property {number} SEQUENCE A string of KEY_IDs.
|
||||
*/
|
||||
whenCodePressed (args) {
|
||||
const sequenceString = Cast.toString(args.SEQUENCE).toUpperCase();
|
||||
const sequenceArray = sequenceString.split(' ');
|
||||
if (sequenceArray.length < 2) {
|
||||
return;
|
||||
}
|
||||
this.addSequence(sequenceString, sequenceArray);
|
||||
|
||||
return this.sequences[sequenceString].completed;
|
||||
}
|
||||
}
|
||||
module.exports = Scratch3MakeyMakeyBlocks;
|
||||
984
scratch-vm/src/extensions/scratch3_microbit/index.js
Normal file
984
scratch-vm/src/extensions/scratch3_microbit/index.js
Normal file
@@ -0,0 +1,984 @@
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
const BlockType = require('../../extension-support/block-type');
|
||||
const log = require('../../util/log');
|
||||
const cast = require('../../util/cast');
|
||||
const formatMessage = require('format-message');
|
||||
const BLE = require('../../io/ble');
|
||||
const Base64Util = require('../../util/base64-util');
|
||||
|
||||
/**
|
||||
* Icon png 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAKcElEQVR42u2cfXAU9RnHv7u3L3d7l9yR5PIGXO7MkQKaYiCUWqJhFGvRMk4JZXSc8aXVaSmiYlthVHQEW99FxiIdrVY6teiMdoa+ICqhIqgQAsjwMgYDOQKXl7uY17u9293b3f5x5JKYe8+FJGSfvzbP/n77e/azz+95nt9v90KoqgpN0hdSQ6AB1ABqADWAmmgANYAaQA2gJhpADeBEE2q8GPLaWzu/CslyiY4k9dOn5uijtXGd7+jWkaReVpT3Hrhv6d0awEFC07rgD+ZeYYnXprhwigUAvjj0zbjxQCLebozT7iDzK1ZUWCru2K7L//6MVC8ue45Blz8n6rlQ815QtuohOlXiEdy/AUqPa6y59Mkh6Q1345GNja6m7pHEQKNl3t0704EXat4L6fSOmOeEI1vHKzwAyNJR9MPFpRUPOu0ONm2A0xatWaTLm5WfDrzvAppA8AbiG03fC8CQNkDKZK2YrPAuRrhpifJERsuYywveJc7CqcIDMAyeLm82dEXzw39I/qjXkpr3QuW9lxfAdOABGAKPslWDnbsy7Jl8BxTeM3SqmO0gaA5U6c3jymup0YSn9JyLee67wpTfBQAQjmyF3HFqiJcRtDECjy5dAmbmcgQPvjjxl3Lx4IVjnD/5cE1zkWtyP34VBGcdKLJnLgc9cznk1kMXFdzEn8KJ4KUqqsSHvcxWDf7j1UM8UPr6/YgHhhX8xAaYaXgAIB7fBnbuSrBzV8aNgarEQ/z6/YkLcDTg9V9XlXjQtuqoU1TpcUHlvZDOfDiuyh5qPMCLrJ1bDw3EuUtx81N/BH3pjQBJQ2HMF5V6iKfeRchVm9kkMtrwxmSdobeA9daBde8GwVlBcFYofS1Jw0vaAy9HeJHQwBUPzIBvGxDc92Rmp/BowJs10wkAONfsBs8HAAAltqngOAO8HZ3o6OiMqcvLy4E1Lwc8H8C5ZndMXdLJa/qNacNLCDBw/O8nFUNWxp/64+tWAwBefe1tHKg7CgC4/9d3ori4EHv3HcDrb26PqVt2602ovvaHaGlpw+8ffSamLqXYmya8jG8mpFy6iGLkWLh4HAwG4+r6j4VBfaPpLgU8IMGO9MLqW2pYQ9aQokuR5dgXIwCC1CUcNMj3hpdvLAdSF54EYpCHooRA0Swomo2pC0kCQpIAkqTA6LmYupgxL0X7m78+aG10NXVkpIwxsAwWXncDCESHLkohfPbpbiT6ZFPPZQ9fC0e58Wi6wTDj6UbT/rQAyiERS2pW4Kc3LQDLRO8miCEAKj7d83FcTxyLJJJJ+9MCqKoq9HomMrgkSThxsgEcZ8AMpwMkSYJlKDA0DVUFiHGWRDJp/4jXwqIo4uFHnkZXdw8AYGbZFXhs3WqQJDkhkkim7E8KoMlkxKbnn8DBunrwUli3e8/+yOAA0HjmHDq7upGXm5PUoDUr7hmWRB5Zt3FYwoime+vtd/H6G9uGJIxouniSyP6H7v8FystnY80jGzIA0MihsMAKu20aTp3JzFb6WCWRuDUvHwByw8cOhw2FBVaYjNzIAba1e3Hfb9aiq7MTNStuBwAsvr4KO3d9GnmKztIS5EyxTJiVSDT7p04tipx/9MnnYc7ORlu7NzMxsK3di5AkDHgGw2DTC+uHBeGJshJJZL/fxyMQEDKbRAiCQDAoQhBDYBkKNE2j4uqrhpUBoiSBIMZfEhkN+1NeiWSqEB2rlUg69md0JRIQRHy86z8jXsqNVRLJlP0jqgNJXXgAgjbCcONmCHUvQ+44NWG2s/rtH5Mt/ciToo0wLH4JBGO6LLazRiJk2vBYy4gHHw/bWSN+LZBKEhkMjzn/CaSiKgQOvJDyFB7L7axUJWNJZDA8IhQA1boPin7KZbMSGfUYyFx9b3hXg/cCsoBA2Z0AoYOaxlcC4+mdyCUDKBzanLFBJ3USyaRMuiSSKZmUSSSTMimTCABUlblRU9kAZ0E39p+eii21c+EL0jHbOwu6sfaWgyjND//U4oP6MmzZnfi79XT7mfQSNi7bh0JzOLG19XBY/89r49pYVebGqhuOosDsh1+gsWV3BXYdd2Q+BlaVuXFv9bHgkSbzk+vfcVRyjHhi47J9cftsXLYf7T36Ix8cLHlo6ydlv6qpPI2qssRZcuOy/Wjp4k5s+2zG+offKqtcUt6kJtNv7S0H0RtkvEufXTB/6bML5je2Wy7UVDbEbF9o9mPDsv2oP5v75vbPS26rP5u3fdXiozDppcwDrKlswOlWy9E//DX09Mt/azh8zzNM1RybF86C7pheVGD240CDeX3NWtfml94Rt+0+Mf3Lm8qbEnpfgdmPs+3G9+564vTT//pM/GrHYduWRP0AYOEMN/5S61xT92Vtfd2XtfWb/vu91fHALyxzw9tnkB/cTD5w+2Ou9375HHtfa7exM5mxRpKFaafdQQKgAcDERs98/foLHrXdaXfoABi8vczhWO2/28/TRR5z2h00gKymNl1ton79oigq6bQ7dE67Q+ew9mb1h4FYYwVESgLAXLSRa+3mWpIdK+UYuPiq89f8+XfT/+ftZQ4vLm9ZmUyfdcsv1M2fWfRaUCK8i8vdK1u6ktuAWPWTsztm24o/cnnYHUsrWzd1+fVJ9XtqxbG3XzFdNcPTawjcueibpxK1t+X26f/9R8a953jub4typOvm2b1XnvUmv8JKWMZcaZffX3XDERRP8cGaFRjWxtPLoZvXY4oxgPBNEsgxBhCUKEzL6Ru+JydS8Ak0giKFgESDJFQoKmCgQzAwIfQEWETzmoBIwd2VNaStu8uEHGO4Buz06zHHFv0dRkefAZ1+PQx0KNK2eIoPLCUj2zDc275qzgcBFWv+cf3IyxgTK2KOzQufEM5kfpGF12eGPSf8DXN+No/87HDWiwYYALw+M6ym8AscAxO++X7xCTRM7EDQzht0Da8v/NWo1dQDAxNCocUXs+303IGHdaptOmYXnh/SLlZbV+fwnwJm6UXEm/ojqgM/PFmJQ81OPHfrtqT7bN23BE8seTflYLvz5DwYGQHLKz5Puo/XZ8aLtT+D1dSDuxbsGQIymmz48DbwIguOESJOcce8XaO3oVpZ8k3Em5KVVAAMFnuOB9as1MbimCBunn04vBmR40ls29Wfgxf1KMn1gBdY+MXUCvK4ANvPndpLzrLzALjBN2VPwrDBksgLYkn1jBMp90nVY2++8vAw3RlPeLNYVZSPAEgjKWP6ZCn4lF+gMdnE08spQb73RQB9aXtgo6tJcNodf8rWz3L//Br340UW3sExEkXrFFKSSUVHqkRfkJZ8QSZk5gS6hw9H+GyDQAclSs41BVmSUIn+toAKIUTJskKoQUknCxKlkISKb/sM0NMyyVAhXW+AlYosfgOgQlUJVadTSUWBKoQoudvPioPbenq5oIUTaRUqenhWKi3oyVIUqKpKREoLggDhF6hQb4CV9LRM9rctMPN6glChp2SdTqeSskwoAECSKnG61fzFR/XsGu+FhmONriYl7TImsjoYKJyZSeB8CoBQo6spqU8TCO1fgE7gDVUNoCYaQA2gBlADqAHURAOoAdQAagA10QCOgfwfNp/hXbfBMCAAAAAASUVORK5CYII=';
|
||||
|
||||
/**
|
||||
* Enum for micro:bit BLE command protocol.
|
||||
* https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
const BLECommand = {
|
||||
CMD_PIN_CONFIG: 0x80,
|
||||
CMD_DISPLAY_TEXT: 0x81,
|
||||
CMD_DISPLAY_LED: 0x82
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A time interval to wait (in milliseconds) before reporting to the BLE socket
|
||||
* that data has stopped coming from the peripheral.
|
||||
*/
|
||||
const BLETimeout = 4500;
|
||||
|
||||
/**
|
||||
* A time interval to wait (in milliseconds) while a block that sends a BLE message is running.
|
||||
* @type {number}
|
||||
*/
|
||||
const BLESendInterval = 100;
|
||||
|
||||
/**
|
||||
* A string to report to the BLE socket when the micro:bit has stopped receiving data.
|
||||
* @type {string}
|
||||
*/
|
||||
const BLEDataStoppedError = 'micro:bit extension stopped receiving data';
|
||||
|
||||
/**
|
||||
* Enum for micro:bit protocol.
|
||||
* https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const BLEUUID = {
|
||||
service: 0xf005,
|
||||
rxChar: '5261da01-fa7e-42ab-850b-7c80220097cc',
|
||||
txChar: '5261da02-fa7e-42ab-850b-7c80220097cc'
|
||||
};
|
||||
|
||||
/**
|
||||
* Manage communication with a MicroBit peripheral over a Scrath Link client socket.
|
||||
*/
|
||||
class MicroBit {
|
||||
|
||||
/**
|
||||
* Construct a MicroBit communication object.
|
||||
* @param {Runtime} runtime - the Scratch 3.0 runtime
|
||||
* @param {string} extensionId - the id of the extension
|
||||
*/
|
||||
constructor (runtime, extensionId) {
|
||||
|
||||
/**
|
||||
* The Scratch 3.0 runtime used to trigger the green flag button.
|
||||
* @type {Runtime}
|
||||
* @private
|
||||
*/
|
||||
this._runtime = runtime;
|
||||
|
||||
/**
|
||||
* The BluetoothLowEnergy connection socket for reading/writing peripheral data.
|
||||
* @type {BLE}
|
||||
* @private
|
||||
*/
|
||||
this._ble = null;
|
||||
this._runtime.registerPeripheralExtension(extensionId, this);
|
||||
|
||||
/**
|
||||
* The id of the extension this peripheral belongs to.
|
||||
*/
|
||||
this._extensionId = extensionId;
|
||||
|
||||
/**
|
||||
* The most recently received value for each sensor.
|
||||
* @type {Object.<string, number>}
|
||||
* @private
|
||||
*/
|
||||
this._sensors = {
|
||||
tiltX: 0,
|
||||
tiltY: 0,
|
||||
buttonA: 0,
|
||||
buttonB: 0,
|
||||
touchPins: [0, 0, 0],
|
||||
gestureState: 0,
|
||||
ledMatrixState: new Uint8Array(5)
|
||||
};
|
||||
|
||||
/**
|
||||
* The most recently received value for each gesture.
|
||||
* @type {Object.<string, Object>}
|
||||
* @private
|
||||
*/
|
||||
this._gestures = {
|
||||
moving: false,
|
||||
move: {
|
||||
active: false,
|
||||
timeout: false
|
||||
},
|
||||
shake: {
|
||||
active: false,
|
||||
timeout: false
|
||||
},
|
||||
jump: {
|
||||
active: false,
|
||||
timeout: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Interval ID for data reading timeout.
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._timeoutID = null;
|
||||
|
||||
/**
|
||||
* A flag that is true while we are busy sending data to the BLE socket.
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
this._busy = false;
|
||||
|
||||
/**
|
||||
* ID for a timeout which is used to clear the busy flag if it has been
|
||||
* true for a long time.
|
||||
*/
|
||||
this._busyTimeoutID = null;
|
||||
|
||||
this.reset = this.reset.bind(this);
|
||||
this._onConnect = this._onConnect.bind(this);
|
||||
this._onMessage = this._onMessage.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text - the text to display.
|
||||
* @return {Promise} - a Promise that resolves when writing to peripheral.
|
||||
*/
|
||||
displayText (text) {
|
||||
const output = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
output[i] = text.charCodeAt(i);
|
||||
}
|
||||
return this.send(BLECommand.CMD_DISPLAY_TEXT, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} matrix - the matrix to display.
|
||||
* @return {Promise} - a Promise that resolves when writing to peripheral.
|
||||
*/
|
||||
displayMatrix (matrix) {
|
||||
return this.send(BLECommand.CMD_DISPLAY_LED, matrix);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {boolean} - the latest value received for the A button.
|
||||
*/
|
||||
get buttonA () {
|
||||
return this._sensors.buttonA;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} - the latest value received for the B button.
|
||||
*/
|
||||
get buttonB () {
|
||||
return this._sensors.buttonB;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} - the latest value received for the motion gesture states.
|
||||
*/
|
||||
get gestureState () {
|
||||
return this._sensors.gestureState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array} - the current state of the 5x5 LED matrix.
|
||||
*/
|
||||
get ledMatrixState () {
|
||||
return this._sensors.ledMatrixState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the runtime when user wants to scan for a peripheral.
|
||||
*/
|
||||
scan () {
|
||||
if (this._ble) {
|
||||
this._ble.disconnect();
|
||||
}
|
||||
this._ble = new BLE(this._runtime, this._extensionId, {
|
||||
filters: [
|
||||
{services: [BLEUUID.service]}
|
||||
]
|
||||
}, this._onConnect, this.reset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the runtime when user wants to connect to a certain peripheral.
|
||||
* @param {number} id - the id of the peripheral to connect to.
|
||||
*/
|
||||
connect (id) {
|
||||
if (this._ble) {
|
||||
this._ble.connectPeripheral(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the micro:bit.
|
||||
*/
|
||||
disconnect () {
|
||||
if (this._ble) {
|
||||
this._ble.disconnect();
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all the state and timeout/interval ids.
|
||||
*/
|
||||
reset () {
|
||||
if (this._timeoutID) {
|
||||
window.clearTimeout(this._timeoutID);
|
||||
this._timeoutID = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if connected to the micro:bit.
|
||||
* @return {boolean} - whether the micro:bit is connected.
|
||||
*/
|
||||
isConnected () {
|
||||
let connected = false;
|
||||
if (this._ble) {
|
||||
connected = this._ble.isConnected();
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the peripheral BLE socket.
|
||||
* @param {number} command - the BLE command hex.
|
||||
* @param {Uint8Array} message - the message to write
|
||||
*/
|
||||
send (command, message) {
|
||||
if (!this.isConnected()) return;
|
||||
if (this._busy) return;
|
||||
|
||||
// Set a busy flag so that while we are sending a message and waiting for
|
||||
// the response, additional messages are ignored.
|
||||
this._busy = true;
|
||||
|
||||
// Set a timeout after which to reset the busy flag. This is used in case
|
||||
// a BLE message was sent for which we never received a response, because
|
||||
// e.g. the peripheral was turned off after the message was sent. We reset
|
||||
// the busy flag after a while so that it is possible to try again later.
|
||||
this._busyTimeoutID = window.setTimeout(() => {
|
||||
this._busy = false;
|
||||
}, 5000);
|
||||
|
||||
const output = new Uint8Array(message.length + 1);
|
||||
output[0] = command; // attach command to beginning of message
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
output[i + 1] = message[i];
|
||||
}
|
||||
const data = Base64Util.uint8ArrayToBase64(output);
|
||||
|
||||
this._ble.write(BLEUUID.service, BLEUUID.txChar, data, 'base64', true).then(
|
||||
() => {
|
||||
this._busy = false;
|
||||
window.clearTimeout(this._busyTimeoutID);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading data from peripheral after BLE has connected to it.
|
||||
* @private
|
||||
*/
|
||||
_onConnect () {
|
||||
this._ble.read(BLEUUID.service, BLEUUID.rxChar, true, this._onMessage);
|
||||
this._timeoutID = window.setTimeout(
|
||||
() => this._ble.handleDisconnectError(BLEDataStoppedError),
|
||||
BLETimeout
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sensor data from the incoming BLE characteristic.
|
||||
* @param {object} base64 - the incoming BLE data.
|
||||
* @private
|
||||
*/
|
||||
_onMessage (base64) {
|
||||
// parse data
|
||||
const data = Base64Util.base64ToUint8Array(base64);
|
||||
|
||||
this._sensors.tiltX = data[1] | (data[0] << 8);
|
||||
if (this._sensors.tiltX > (1 << 15)) this._sensors.tiltX -= (1 << 16);
|
||||
this._sensors.tiltY = data[3] | (data[2] << 8);
|
||||
if (this._sensors.tiltY > (1 << 15)) this._sensors.tiltY -= (1 << 16);
|
||||
|
||||
this._sensors.buttonA = data[4];
|
||||
this._sensors.buttonB = data[5];
|
||||
|
||||
this._sensors.touchPins[0] = data[6];
|
||||
this._sensors.touchPins[1] = data[7];
|
||||
this._sensors.touchPins[2] = data[8];
|
||||
|
||||
this._sensors.gestureState = data[9];
|
||||
|
||||
// cancel disconnect timeout and start a new one
|
||||
window.clearTimeout(this._timeoutID);
|
||||
this._timeoutID = window.setTimeout(
|
||||
() => this._ble.handleDisconnectError(BLEDataStoppedError),
|
||||
BLETimeout
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} pin - the pin to check touch state.
|
||||
* @return {number} - the latest value received for the touch pin states.
|
||||
* @private
|
||||
*/
|
||||
_checkPinState (pin) {
|
||||
return this._sensors.touchPins[pin];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for tilt sensor direction.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const MicroBitTiltDirection = {
|
||||
FRONT: 'front',
|
||||
BACK: 'back',
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
ANY: 'any'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for micro:bit gestures.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const MicroBitGestures = {
|
||||
MOVED: 'moved',
|
||||
SHAKEN: 'shaken',
|
||||
JUMPED: 'jumped'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for micro:bit buttons.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const MicroBitButtons = {
|
||||
A: 'A',
|
||||
B: 'B',
|
||||
ANY: 'any'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for micro:bit pin states.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const MicroBitPinState = {
|
||||
ON: 'on',
|
||||
OFF: 'off'
|
||||
};
|
||||
|
||||
/**
|
||||
* Scratch 3.0 blocks to interact with a MicroBit peripheral.
|
||||
*/
|
||||
class Scratch3MicroBitBlocks {
|
||||
|
||||
/**
|
||||
* @return {string} - the name of this extension.
|
||||
*/
|
||||
static get EXTENSION_NAME () {
|
||||
return 'micro:bit';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string} - the ID of this extension.
|
||||
*/
|
||||
static get EXTENSION_ID () {
|
||||
return 'microbit';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} - the tilt sensor counts as "tilted" if its tilt angle meets or exceeds this threshold.
|
||||
*/
|
||||
static get TILT_THRESHOLD () {
|
||||
return 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {array} - text and values for each buttons menu element
|
||||
*/
|
||||
get BUTTONS_MENU () {
|
||||
return [
|
||||
{
|
||||
text: 'A',
|
||||
value: MicroBitButtons.A
|
||||
},
|
||||
{
|
||||
text: 'B',
|
||||
value: MicroBitButtons.B
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.buttonsMenu.any',
|
||||
default: 'any',
|
||||
description: 'label for "any" element in button picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitButtons.ANY
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {array} - text and values for each gestures menu element
|
||||
*/
|
||||
get GESTURES_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.gesturesMenu.moved',
|
||||
default: 'moved',
|
||||
description: 'label for moved gesture in gesture picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitGestures.MOVED
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.gesturesMenu.shaken',
|
||||
default: 'shaken',
|
||||
description: 'label for shaken gesture in gesture picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitGestures.SHAKEN
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.gesturesMenu.jumped',
|
||||
default: 'jumped',
|
||||
description: 'label for jumped gesture in gesture picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitGestures.JUMPED
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {array} - text and values for each pin state menu element
|
||||
*/
|
||||
get PIN_STATE_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.pinStateMenu.on',
|
||||
default: 'on',
|
||||
description: 'label for on element in pin state picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitPinState.ON
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.pinStateMenu.off',
|
||||
default: 'off',
|
||||
description: 'label for off element in pin state picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitPinState.OFF
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {array} - text and values for each tilt direction menu element
|
||||
*/
|
||||
get TILT_DIRECTION_MENU () {
|
||||
return [
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltDirectionMenu.front',
|
||||
default: 'front',
|
||||
description: 'label for front element in tilt direction picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitTiltDirection.FRONT
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltDirectionMenu.back',
|
||||
default: 'back',
|
||||
description: 'label for back element in tilt direction picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitTiltDirection.BACK
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltDirectionMenu.left',
|
||||
default: 'left',
|
||||
description: 'label for left element in tilt direction picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitTiltDirection.LEFT
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltDirectionMenu.right',
|
||||
default: 'right',
|
||||
description: 'label for right element in tilt direction picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitTiltDirection.RIGHT
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {array} - text and values for each tilt direction (plus "any") menu element
|
||||
*/
|
||||
get TILT_DIRECTION_ANY_MENU () {
|
||||
return [
|
||||
...this.TILT_DIRECTION_MENU,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltDirectionMenu.any',
|
||||
default: 'any',
|
||||
description: 'label for any direction element in tilt direction picker for micro:bit extension'
|
||||
}),
|
||||
value: MicroBitTiltDirection.ANY
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a set of MicroBit blocks.
|
||||
* @param {Runtime} runtime - the Scratch 3.0 runtime.
|
||||
*/
|
||||
constructor (runtime) {
|
||||
/**
|
||||
* The Scratch 3.0 runtime.
|
||||
* @type {Runtime}
|
||||
*/
|
||||
this.runtime = runtime;
|
||||
|
||||
// Create a new MicroBit peripheral instance
|
||||
this._peripheral = new MicroBit(this.runtime, Scratch3MicroBitBlocks.EXTENSION_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} metadata for this extension and its blocks.
|
||||
*/
|
||||
getInfo () {
|
||||
return {
|
||||
id: Scratch3MicroBitBlocks.EXTENSION_ID,
|
||||
name: Scratch3MicroBitBlocks.EXTENSION_NAME,
|
||||
blockIconURI: blockIconURI,
|
||||
showStatusButton: true,
|
||||
blocks: [
|
||||
{
|
||||
opcode: 'whenButtonPressed',
|
||||
text: formatMessage({
|
||||
id: 'microbit.whenButtonPressed',
|
||||
default: 'when [BTN] button pressed',
|
||||
description: 'when the selected button on the micro:bit is pressed'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
BTN: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'buttons',
|
||||
defaultValue: MicroBitButtons.A
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'isButtonPressed',
|
||||
text: formatMessage({
|
||||
id: 'microbit.isButtonPressed',
|
||||
default: '[BTN] button pressed?',
|
||||
description: 'is the selected button on the micro:bit pressed?'
|
||||
}),
|
||||
blockType: BlockType.BOOLEAN,
|
||||
arguments: {
|
||||
BTN: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'buttons',
|
||||
defaultValue: MicroBitButtons.A
|
||||
}
|
||||
}
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'whenGesture',
|
||||
text: formatMessage({
|
||||
id: 'microbit.whenGesture',
|
||||
default: 'when [GESTURE]',
|
||||
description: 'when the selected gesture is detected by the micro:bit'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
GESTURE: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'gestures',
|
||||
defaultValue: MicroBitGestures.MOVED
|
||||
}
|
||||
}
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'displaySymbol',
|
||||
text: formatMessage({
|
||||
id: 'microbit.displaySymbol',
|
||||
default: 'display [MATRIX]',
|
||||
description: 'display a pattern on the micro:bit display'
|
||||
}),
|
||||
blockType: BlockType.COMMAND,
|
||||
arguments: {
|
||||
MATRIX: {
|
||||
type: ArgumentType.MATRIX,
|
||||
defaultValue: '0101010101100010101000100'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'displayText',
|
||||
text: formatMessage({
|
||||
id: 'microbit.displayText',
|
||||
default: 'display text [TEXT]',
|
||||
description: 'display text on the micro:bit display'
|
||||
}),
|
||||
blockType: BlockType.COMMAND,
|
||||
arguments: {
|
||||
TEXT: {
|
||||
type: ArgumentType.STRING,
|
||||
defaultValue: formatMessage({
|
||||
id: 'microbit.defaultTextToDisplay',
|
||||
default: 'Hello!',
|
||||
description: `default text to display.
|
||||
IMPORTANT - the micro:bit only supports letters a-z, A-Z.
|
||||
Please substitute a default word in your language
|
||||
that can be written with those characters,
|
||||
substitute non-accented characters or leave it as "Hello!".
|
||||
Check the micro:bit site documentation for details`
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'displayClear',
|
||||
text: formatMessage({
|
||||
id: 'microbit.clearDisplay',
|
||||
default: 'clear display',
|
||||
description: 'display nothing on the micro:bit display'
|
||||
}),
|
||||
blockType: BlockType.COMMAND
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'whenTilted',
|
||||
text: formatMessage({
|
||||
id: 'microbit.whenTilted',
|
||||
default: 'when tilted [DIRECTION]',
|
||||
description: 'when the micro:bit is tilted in a direction'
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
DIRECTION: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltDirectionAny',
|
||||
defaultValue: MicroBitTiltDirection.ANY
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'isTilted',
|
||||
text: formatMessage({
|
||||
id: 'microbit.isTilted',
|
||||
default: 'tilted [DIRECTION]?',
|
||||
description: 'is the micro:bit is tilted in a direction?'
|
||||
}),
|
||||
blockType: BlockType.BOOLEAN,
|
||||
arguments: {
|
||||
DIRECTION: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltDirectionAny',
|
||||
defaultValue: MicroBitTiltDirection.ANY
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'getTiltAngle',
|
||||
text: formatMessage({
|
||||
id: 'microbit.tiltAngle',
|
||||
default: 'tilt angle [DIRECTION]',
|
||||
description: 'how much the micro:bit is tilted in a direction'
|
||||
}),
|
||||
blockType: BlockType.REPORTER,
|
||||
arguments: {
|
||||
DIRECTION: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'tiltDirection',
|
||||
defaultValue: MicroBitTiltDirection.FRONT
|
||||
}
|
||||
}
|
||||
},
|
||||
'---',
|
||||
{
|
||||
opcode: 'whenPinConnected',
|
||||
text: formatMessage({
|
||||
id: 'microbit.whenPinConnected',
|
||||
default: 'when pin [PIN] connected',
|
||||
description: 'when the pin detects a connection to Earth/Ground'
|
||||
|
||||
}),
|
||||
blockType: BlockType.HAT,
|
||||
arguments: {
|
||||
PIN: {
|
||||
type: ArgumentType.STRING,
|
||||
menu: 'touchPins',
|
||||
defaultValue: '0'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
menus: {
|
||||
buttons: {
|
||||
acceptReporters: true,
|
||||
items: this.BUTTONS_MENU
|
||||
},
|
||||
gestures: {
|
||||
acceptReporters: true,
|
||||
items: this.GESTURES_MENU
|
||||
},
|
||||
pinState: {
|
||||
acceptReporters: true,
|
||||
items: this.PIN_STATE_MENU
|
||||
},
|
||||
tiltDirection: {
|
||||
acceptReporters: true,
|
||||
items: this.TILT_DIRECTION_MENU
|
||||
},
|
||||
tiltDirectionAny: {
|
||||
acceptReporters: true,
|
||||
items: this.TILT_DIRECTION_ANY_MENU
|
||||
},
|
||||
touchPins: {
|
||||
acceptReporters: true,
|
||||
items: ['0', '1', '2']
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the A or B button is pressed
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {boolean} - true if the button is pressed.
|
||||
*/
|
||||
whenButtonPressed (args) {
|
||||
if (args.BTN === 'any') {
|
||||
return this._peripheral.buttonA | this._peripheral.buttonB;
|
||||
} else if (args.BTN === 'A') {
|
||||
return this._peripheral.buttonA;
|
||||
} else if (args.BTN === 'B') {
|
||||
return this._peripheral.buttonB;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the A or B button is pressed
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {boolean} - true if the button is pressed.
|
||||
*/
|
||||
isButtonPressed (args) {
|
||||
if (args.BTN === 'any') {
|
||||
return (this._peripheral.buttonA | this._peripheral.buttonB) !== 0;
|
||||
} else if (args.BTN === 'A') {
|
||||
return this._peripheral.buttonA !== 0;
|
||||
} else if (args.BTN === 'B') {
|
||||
return this._peripheral.buttonB !== 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the micro:bit is moving
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {boolean} - true if the micro:bit is moving.
|
||||
*/
|
||||
whenGesture (args) {
|
||||
const gesture = cast.toString(args.GESTURE);
|
||||
if (gesture === 'moved') {
|
||||
return (this._peripheral.gestureState >> 2) & 1;
|
||||
} else if (gesture === 'shaken') {
|
||||
return this._peripheral.gestureState & 1;
|
||||
} else if (gesture === 'jumped') {
|
||||
return (this._peripheral.gestureState >> 1) & 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a predefined symbol on the 5x5 LED matrix.
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {Promise} - a Promise that resolves after a tick.
|
||||
*/
|
||||
displaySymbol (args) {
|
||||
const symbol = cast.toString(args.MATRIX).replace(/\s/g, '');
|
||||
const reducer = (accumulator, c, index) => {
|
||||
const value = (c === '0') ? accumulator : accumulator + Math.pow(2, index);
|
||||
return value;
|
||||
};
|
||||
const hex = symbol.split('').reduce(reducer, 0);
|
||||
if (hex !== null) {
|
||||
this._peripheral.ledMatrixState[0] = hex & 0x1F;
|
||||
this._peripheral.ledMatrixState[1] = (hex >> 5) & 0x1F;
|
||||
this._peripheral.ledMatrixState[2] = (hex >> 10) & 0x1F;
|
||||
this._peripheral.ledMatrixState[3] = (hex >> 15) & 0x1F;
|
||||
this._peripheral.ledMatrixState[4] = (hex >> 20) & 0x1F;
|
||||
this._peripheral.displayMatrix(this._peripheral.ledMatrixState);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, BLESendInterval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display text on the 5x5 LED matrix.
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {Promise} - a Promise that resolves after the text is done printing.
|
||||
* Note the limit is 19 characters
|
||||
* The print time is calculated by multiplying the number of horizontal pixels
|
||||
* by the default scroll delay of 120ms.
|
||||
* The number of horizontal pixels = 6px for each character in the string,
|
||||
* 1px before the string, and 5px after the string.
|
||||
*/
|
||||
displayText (args) {
|
||||
const text = String(args.TEXT).substring(0, 19);
|
||||
if (text.length > 0) this._peripheral.displayText(text);
|
||||
const yieldDelay = 120 * ((6 * text.length) + 6);
|
||||
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, yieldDelay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn all 5x5 matrix LEDs off.
|
||||
* @return {Promise} - a Promise that resolves after a tick.
|
||||
*/
|
||||
displayClear () {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
this._peripheral.ledMatrixState[i] = 0;
|
||||
}
|
||||
this._peripheral.displayMatrix(this._peripheral.ledMatrixState);
|
||||
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, BLESendInterval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the tilt sensor is currently tilted.
|
||||
* @param {object} args - the block's arguments.
|
||||
* @property {TiltDirection} DIRECTION - the tilt direction to test (front, back, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the tilt sensor is currently tilted.
|
||||
* @param {object} args - the block's arguments.
|
||||
* @property {TiltDirection} DIRECTION - the tilt direction to test (front, back, 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 (front, back, left, right) to check.
|
||||
* @return {number} - the tilt sensor's angle in the specified direction.
|
||||
* Note that getTiltAngle(front) = -getTiltAngle(back) 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 (front, back, 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 MicroBitTiltDirection.ANY:
|
||||
return (Math.abs(this._peripheral.tiltX / 10) >= Scratch3MicroBitBlocks.TILT_THRESHOLD) ||
|
||||
(Math.abs(this._peripheral.tiltY / 10) >= Scratch3MicroBitBlocks.TILT_THRESHOLD);
|
||||
default:
|
||||
return this._getTiltAngle(direction) >= Scratch3MicroBitBlocks.TILT_THRESHOLD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TiltDirection} direction - the direction (front, back, left, right) to check.
|
||||
* @return {number} - the tilt sensor's angle in the specified direction.
|
||||
* Note that getTiltAngle(front) = -getTiltAngle(back) and getTiltAngle(left) = -getTiltAngle(right).
|
||||
* @private
|
||||
*/
|
||||
_getTiltAngle (direction) {
|
||||
switch (direction) {
|
||||
case MicroBitTiltDirection.FRONT:
|
||||
return Math.round(this._peripheral.tiltY / -10);
|
||||
case MicroBitTiltDirection.BACK:
|
||||
return Math.round(this._peripheral.tiltY / 10);
|
||||
case MicroBitTiltDirection.LEFT:
|
||||
return Math.round(this._peripheral.tiltX / -10);
|
||||
case MicroBitTiltDirection.RIGHT:
|
||||
return Math.round(this._peripheral.tiltX / 10);
|
||||
default:
|
||||
log.warn(`Unknown tilt direction in _getTiltAngle: ${direction}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} args - the block's arguments.
|
||||
* @return {boolean} - the touch pin state.
|
||||
* @private
|
||||
*/
|
||||
whenPinConnected (args) {
|
||||
const pin = parseInt(args.PIN, 10);
|
||||
if (isNaN(pin)) return;
|
||||
if (pin < 0 || pin > 2) return false;
|
||||
return this._peripheral._checkPinState(pin);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scratch3MicroBitBlocks;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user