diff --git a/.gitignore b/.gitignore index 03f6901597..4a32dff77b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ # npm node_modules -# Final built website +# Final built website and localizations build +build-l10n + +# Various operating system caches +thumbs.db +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16779c139d..e1017ab42a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,10 @@ We're all volunteers who all have lives outside of Scratch extensions. Many have Every extension is also covered under [our bug bounty](https://github.com/TurboWarp/extensions/security/policy), so mindlessly merging things will have a direct impact on my wallet. +## On AI language models + +**Generative AI language models like ChatGPT, Bing Chat, and Bard DO NOT know how to write proper extensions for TurboWarp.** Remember that the ChatGPT knowledge cutoff is in 2021 while our extension system did not exist until late 2022, thus it *literally can't know*. Pull requests submitting extensions that are made by AI (it's really obvious) will be closed as invalid. + ## Writing extensions Extension source code goes in the [`extensions`](extensions) folder. For example, an extension placed at `extensions/hello-world.js` would be accessible at [http://localhost:8000/hello-world.js](http://localhost:8000/hello-world.js) using our development server. diff --git a/development/build-production.js b/development/build-production.js index 39f50abffe..acfe878b40 100644 --- a/development/build-production.js +++ b/development/build-production.js @@ -1,10 +1,14 @@ const pathUtil = require("path"); const Builder = require("./builder"); -const outputDirectory = pathUtil.join(__dirname, "..", "build"); +const outputDirectory = pathUtil.join(__dirname, "../build"); +const l10nOutput = pathUtil.join(__dirname, "../build-l10n"); const builder = new Builder("production"); const build = builder.build(); + build.export(outputDirectory); +console.log(`Built to ${outputDirectory}`); -console.log(`Saved to ${outputDirectory}`); +build.exportL10N(l10nOutput); +console.log(`Exported L10N to ${l10nOutput}`); diff --git a/development/builder.js b/development/builder.js index 2ca29d13c4..23a6d9714b 100644 --- a/development/builder.js +++ b/development/builder.js @@ -1,13 +1,19 @@ const fs = require("fs"); +const AdmZip = require("adm-zip"); const pathUtil = require("path"); const compatibilityAliases = require("./compatibility-aliases"); const parseMetadata = require("./parse-extension-metadata"); -const featuredExtensionSlugs = require("../extensions/extensions.json"); /** * @typedef {'development'|'production'|'desktop'} Mode */ +/** + * @typedef TranslatableString + * @property {string} string The English version of the string + * @property {string} developer_comment Helper text to help translators + */ + /** * Recursively read a directory. * @param {string} directory @@ -38,6 +44,107 @@ const recursiveReadDirectory = (directory) => { return result; }; +/** + * Synchronous create a directory and any parents. Does nothing if the folder already exists. + * @param {string} directory + */ +const mkdirp = (directory) => { + try { + fs.mkdirSync(directory, { + recursive: true, + }); + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } +}; + +/** + * @param {Record>} allTranslations + * @param {string} idPrefix + * @returns {Record>|null} + */ +const filterTranslationsByPrefix = (allTranslations, idPrefix) => { + let translationsEmpty = true; + const filteredTranslations = {}; + + for (const [locale, strings] of Object.entries(allTranslations)) { + let localeEmpty = true; + const filteredStrings = {}; + + for (const [id, string] of Object.entries(strings)) { + if (id.startsWith(idPrefix)) { + filteredStrings[id.substring(idPrefix.length)] = string; + localeEmpty = false; + } + } + + if (!localeEmpty) { + filteredTranslations[locale] = filteredStrings; + translationsEmpty = false; + } + } + + return translationsEmpty ? null : filteredTranslations; +}; + +/** + * @param {Record>} allTranslations + * @param {string} idFilter + * @returns {Record} + */ +const filterTranslationsByID = (allTranslations, idFilter) => { + let stringsEmpty = true; + const result = {}; + + for (const [locale, strings] of Object.entries(allTranslations)) { + const translated = strings[idFilter]; + if (translated) { + result[locale] = translated; + stringsEmpty = false; + } + } + + return stringsEmpty ? null : result; +}; + +/** + * @param {string} oldCode + * @param {string} insertCode + */ +const insertAfterCommentsBeforeCode = (oldCode, insertCode) => { + let index = 0; + while (true) { + if (oldCode.substring(index, index + 2) === "//") { + // Line comment + const end = oldCode.indexOf("\n", index); + if (end === -1) { + // This file is only line comments + index = oldCode.length; + break; + } + index = end; + } else if (oldCode.substring(index, index + 2) === "/*") { + // Block comment + const end = oldCode.indexOf("*/", index); + if (end === -1) { + throw new Error("Block comment never ends"); + } + index = end + 2; + } else if (/\s/.test(oldCode.charAt(index))) { + // Whitespace + index++; + } else { + break; + } + } + + const before = oldCode.substring(0, index); + const after = oldCode.substring(index); + return before + insertCode + after; +}; + class BuildFile { constructor(source) { this.sourcePath = source; @@ -58,12 +165,55 @@ class BuildFile { validate() { // no-op by default } + + /** + * @returns {Record>|null} + */ + getStrings() { + // no-op by default, to be overridden + return null; + } } class ExtensionFile extends BuildFile { - constructor(absolutePath, featured) { + /** + * @param {string} absolutePath Full path to the .js file, eg. /home/.../extensions/fetch.js + * @param {string} slug Just the extension ID from the path, eg. fetch + * @param {boolean} featured true if the extension is the homepage + * @param {Record>} allTranslations All extension runtime translations + * @param {Mode} mode + */ + constructor(absolutePath, slug, featured, allTranslations, mode) { super(absolutePath); + /** @type {string} */ + this.slug = slug; + /** @type {boolean} */ this.featured = featured; + /** @type {Record>} */ + this.allTranslations = allTranslations; + /** @type {Mode} */ + this.mode = mode; + } + + read() { + const data = fs.readFileSync(this.sourcePath, "utf-8"); + + if (this.mode !== "development") { + const translations = filterTranslationsByPrefix( + this.allTranslations, + `${this.slug}@` + ); + if (translations !== null) { + return insertAfterCommentsBeforeCode( + data, + `/* generated l10n code */Scratch.translate.setup(${JSON.stringify( + translations + )});/* end generated l10n code */` + ); + } + } + + return data; } getMetadata() { @@ -115,10 +265,59 @@ class ExtensionFile extends BuildFile { } } } + + getStrings() { + if (!this.featured) { + return null; + } + + const metadata = this.getMetadata(); + const slug = this.slug; + + const getMetadataDescription = (part) => { + let result = `${part} of the '${metadata.name}' extension in the extension gallery.`; + if (metadata.context) { + result += ` ${metadata.context}`; + } + return result; + }; + const metadataStrings = { + [`${slug}@name`]: { + string: metadata.name, + developer_comment: getMetadataDescription("Name"), + }, + [`${slug}@description`]: { + string: metadata.description, + developer_comment: getMetadataDescription("Description"), + }, + }; + + const parseTranslations = require("./parse-extension-translations"); + const jsCode = fs.readFileSync(this.sourcePath, "utf-8"); + const unprefixedRuntimeStrings = parseTranslations(jsCode); + const runtimeStrings = Object.fromEntries( + Object.entries(unprefixedRuntimeStrings).map(([key, value]) => [ + `${slug}@${key}`, + value, + ]) + ); + + return { + "extension-metadata": metadataStrings, + "extension-runtime": runtimeStrings, + }; + } } class HomepageFile extends BuildFile { - constructor(extensionFiles, extensionImages, mode) { + constructor( + extensionFiles, + extensionImages, + featuredSlugs, + withDocs, + samples, + mode + ) { super(pathUtil.join(__dirname, "homepage-template.ejs")); /** @type {Record} */ @@ -127,6 +326,15 @@ class HomepageFile extends BuildFile { /** @type {Record} */ this.extensionImages = extensionImages; + /** @type {string[]} */ + this.featuredSlugs = featuredSlugs; + + /** @type {Map} */ + this.withDocs = withDocs; + + /** @type {SampleFile[]} */ + this.samples = samples; + /** @type {Mode} */ this.mode = mode; @@ -144,12 +352,25 @@ class HomepageFile extends BuildFile { return `${this.host}${extensionSlug}.js`; } + getDocumentationURL(extensionSlug) { + return `${this.host}${extensionSlug}`; + } + getRunExtensionURL(extensionSlug) { return `https://turbowarp.org/editor?extension=${this.getFullExtensionURL( extensionSlug )}`; } + /** + * @param {SampleFile} sampleFile + * @returns {string} + */ + getRunSampleURL(sampleFile) { + const path = encodeURIComponent(`samples/${sampleFile.getSlug()}`); + return `https://turbowarp.org/editor?project_url=${this.host}${path}`; + } + read() { const renderTemplate = require("./render-template"); @@ -159,9 +380,13 @@ class HomepageFile extends BuildFile { .map((i) => i[0]); const extensionMetadata = Object.fromEntries( - featuredExtensionSlugs.map((id) => [ - id, - this.extensionFiles[id].getMetadata(), + this.featuredSlugs.map((slug) => [ + slug, + { + ...this.extensionFiles[slug].getMetadata(), + hasDocumentation: this.withDocs.has(slug), + samples: this.samples.get(slug) || [], + }, ]) ); @@ -172,12 +397,21 @@ class HomepageFile extends BuildFile { extensionMetadata, getFullExtensionURL: this.getFullExtensionURL.bind(this), getRunExtensionURL: this.getRunExtensionURL.bind(this), + getDocumentationURL: this.getDocumentationURL.bind(this), + getRunSampleURL: this.getRunSampleURL.bind(this), }); } } class JSONMetadataFile extends BuildFile { - constructor(extensionFiles, extensionImages) { + constructor( + extensionFiles, + extensionImages, + featuredSlugs, + withDocs, + samples, + allTranslations + ) { super(null); /** @type {Record} */ @@ -185,6 +419,18 @@ class JSONMetadataFile extends BuildFile { /** @type {Record} */ this.extensionImages = extensionImages; + + /** @type {string[]} */ + this.featuredSlugs = featuredSlugs; + + /** @type {Set} */ + this.withDocs = withDocs; + + /** @type {Map} */ + this.samples = samples; + + /** @type {Record>} */ + this.allTranslations = allTranslations; } getType() { @@ -193,7 +439,7 @@ class JSONMetadataFile extends BuildFile { read() { const extensions = []; - for (const extensionSlug of featuredExtensionSlugs) { + for (const extensionSlug of this.featuredSlugs) { const extension = {}; const file = this.extensionFiles[extensionSlug]; const metadata = file.getMetadata(); @@ -201,8 +447,28 @@ class JSONMetadataFile extends BuildFile { extension.slug = extensionSlug; extension.id = metadata.id; + + // English fields extension.name = metadata.name; extension.description = metadata.description; + + // For other languages, translations go here. + // This system is a bit silly to avoid backwards-incompatible JSON changes. + const nameTranslations = filterTranslationsByID( + this.allTranslations, + `${extensionSlug}@name` + ); + if (nameTranslations) { + extension.nameTranslations = nameTranslations; + } + const descriptionTranslations = filterTranslationsByID( + this.allTranslations, + `${extensionSlug}@description` + ); + if (descriptionTranslations) { + extension.descriptionTranslations = descriptionTranslations; + } + if (image) { extension.image = image; } @@ -212,6 +478,13 @@ class JSONMetadataFile extends BuildFile { if (metadata.original.length) { extension.original = metadata.original; } + if (this.withDocs.has(extensionSlug)) { + extension.docs = true; + } + const samples = this.samples.get(extensionSlug); + if (samples) { + extension.samples = samples.map((i) => i.getTitle()); + } extensions.push(extension); } @@ -252,6 +525,11 @@ class SVGFile extends ImageFile { } } +const IMAGE_FORMATS = new Map(); +IMAGE_FORMATS.set(".png", ImageFile); +IMAGE_FORMATS.set(".jpg", ImageFile); +IMAGE_FORMATS.set(".svg", SVGFile); + class SitemapFile extends BuildFile { constructor(build) { super(null); @@ -284,11 +562,6 @@ class SitemapFile extends BuildFile { } } -const IMAGE_FORMATS = new Map(); -IMAGE_FORMATS.set(".png", ImageFile); -IMAGE_FORMATS.set(".jpg", ImageFile); -IMAGE_FORMATS.set(".svg", SVGFile); - class DocsFile extends BuildFile { constructor(absolutePath, extensionSlug) { super(absolutePath); @@ -306,8 +579,47 @@ class DocsFile extends BuildFile { } } +class SampleFile extends BuildFile { + getSlug() { + return pathUtil.basename(this.sourcePath); + } + + getTitle() { + return this.getSlug().replace(".sb3", ""); + } + + /** @returns {string[]} list of full URLs */ + getExtensionURLs() { + const zip = new AdmZip(this.sourcePath); + const entry = zip.getEntry("project.json"); + if (!entry) { + throw new Error("package.json missing"); + } + const data = JSON.parse(entry.getData().toString("utf-8")); + return data.extensionURLs ? Object.values(data.extensionURLs) : []; + } + + validate() { + const urls = this.getExtensionURLs(); + + if (urls.length === 0) { + throw new Error("Has no extensions"); + } + + for (const url of urls) { + if ( + !url.startsWith("https://extensions.turbowarp.org/") || + !url.endsWith(".js") + ) { + throw new Error(`Invalid extension URL for sample: ${url}`); + } + } + } +} + class Build { constructor() { + /** @type {Record} */ this.files = {}; } @@ -321,15 +633,7 @@ class Build { } export(root) { - try { - fs.rmSync(root, { - recursive: true, - }); - } catch (e) { - if (e.code !== "ENOENT") { - throw e; - } - } + mkdirp(root); for (const [relativePath, file] of Object.entries(this.files)) { const directoryName = pathUtil.dirname(relativePath); @@ -339,6 +643,58 @@ class Build { fs.writeFileSync(pathUtil.join(root, relativePath), file.read()); } } + + /** + * @returns {Record>} + */ + generateL10N() { + const allStrings = {}; + + for (const [filePath, file] of Object.entries(this.files)) { + let fileStrings; + try { + fileStrings = file.getStrings(); + } catch (error) { + console.error(error); + throw new Error( + `Error getting translations from ${filePath}: ${error}, see above` + ); + } + if (!fileStrings) { + continue; + } + + for (const [group, strings] of Object.entries(fileStrings)) { + if (!allStrings[group]) { + allStrings[group] = {}; + } + + for (const [key, value] of Object.entries(strings)) { + if (allStrings[key]) { + throw new Error( + `L10N collision: multiple instances of ${key} in group ${group}` + ); + } + allStrings[group][key] = value; + } + } + } + + return allStrings; + } + + /** + * @param {string} root + */ + exportL10N(root) { + mkdirp(root); + + const groups = this.generateL10N(); + for (const [name, strings] of Object.entries(groups)) { + const filename = pathUtil.join(root, `exported-${name}.json`); + fs.writeFileSync(filename, JSON.stringify(strings, null, 2)); + } + } } class Builder { @@ -361,11 +717,36 @@ class Builder { this.websiteRoot = pathUtil.join(__dirname, "../website"); this.imagesRoot = pathUtil.join(__dirname, "../images"); this.docsRoot = pathUtil.join(__dirname, "../docs"); + this.samplesRoot = pathUtil.join(__dirname, "../samples"); + this.translationsRoot = pathUtil.join(__dirname, "../translations"); } build() { const build = new Build(this.mode); + const featuredExtensionSlugs = JSON.parse( + fs.readFileSync( + pathUtil.join(this.extensionsRoot, "extensions.json"), + "utf-8" + ) + ); + + /** + * Look up by [group][locale][id] + * @type {Record>>} + */ + const translations = {}; + for (const [filename, absolutePath] of recursiveReadDirectory( + this.translationsRoot + )) { + if (!filename.endsWith(".json")) { + continue; + } + const group = filename.split(".")[0]; + const data = JSON.parse(fs.readFileSync(absolutePath, "utf-8")); + translations[group] = data; + } + /** @type {Record} */ const extensionFiles = {}; for (const [filename, absolutePath] of recursiveReadDirectory( @@ -376,7 +757,13 @@ class Builder { } const extensionSlug = filename.split(".")[0]; const featured = featuredExtensionSlugs.includes(extensionSlug); - const file = new ExtensionFile(absolutePath, featured); + const file = new ExtensionFile( + absolutePath, + extensionSlug, + featured, + translations["extension-runtime"], + this.mode + ); extensionFiles[extensionSlug] = file; build.files[`/${filename}`] = file; } @@ -398,13 +785,37 @@ class Builder { build.files[`/images/${filename}`] = new ImageFileClass(absolutePath); } - if (this.mode !== "desktop") { - for (const [filename, absolutePath] of recursiveReadDirectory( - this.websiteRoot - )) { - build.files[`/${filename}`] = new BuildFile(absolutePath); + /** @type {Set} */ + const extensionsWithDocs = new Set(); + + /** @type {Map} */ + const samples = new Map(); + for (const [filename, absolutePath] of recursiveReadDirectory( + this.samplesRoot + )) { + if (!filename.endsWith(".sb3")) { + continue; } + const file = new SampleFile(absolutePath); + for (const url of file.getExtensionURLs()) { + const slug = new URL(url).pathname.substring(1).replace(".js", ""); + if (samples.has(slug)) { + samples.get(slug).push(file); + } else { + samples.set(slug, [file]); + } + } + build.files[`/samples/${filename}`] = file; + } + + for (const [filename, absolutePath] of recursiveReadDirectory( + this.websiteRoot + )) { + build.files[`/${filename}`] = new BuildFile(absolutePath); + } + + if (this.mode !== "desktop") { for (const [filename, absolutePath] of recursiveReadDirectory( this.docsRoot )) { @@ -412,15 +823,14 @@ class Builder { continue; } const extensionSlug = filename.split(".")[0]; - build.files[`/${extensionSlug}.html`] = new DocsFile( - absolutePath, - extensionSlug - ); + const file = new DocsFile(absolutePath, extensionSlug); + extensionsWithDocs.add(extensionSlug); + build.files[`/${extensionSlug}.html`] = file; } const scratchblocksPath = pathUtil.join( __dirname, - "../node_modules/scratchblocks/build/scratchblocks.min.js" + "../node_modules/@turbowarp/scratchblocks/build/scratchblocks.min.js" ); build.files["/docs-internal/scratchblocks.js"] = new BuildFile( scratchblocksPath @@ -429,13 +839,23 @@ class Builder { build.files["/index.html"] = new HomepageFile( extensionFiles, extensionImages, + featuredExtensionSlugs, + extensionsWithDocs, + samples, this.mode ); build.files["/sitemap.xml"] = new SitemapFile(build); } build.files["/generated-metadata/extensions-v0.json"] = - new JSONMetadataFile(extensionFiles, extensionImages); + new JSONMetadataFile( + extensionFiles, + extensionImages, + featuredExtensionSlugs, + extensionsWithDocs, + samples, + translations["extension-metadata"] + ); for (const [oldPath, newPath] of Object.entries(compatibilityAliases)) { build.files[oldPath] = build.files[newPath]; @@ -472,6 +892,8 @@ class Builder { `${this.imagesRoot}/**/*`, `${this.websiteRoot}/**/*`, `${this.docsRoot}/**/*`, + `${this.samplesRoot}/**/*`, + `${this.translationsRoot}/**/*`, ], { ignoreInitial: true, diff --git a/development/homepage-template.ejs b/development/homepage-template.ejs index 18fd2a84fa..95b3c5fbf3 100644 --- a/development/homepage-template.ejs +++ b/development/homepage-template.ejs @@ -107,6 +107,7 @@ } .extension { + position: relative; border: 2px solid #ccc; border-radius: 8px; margin: 4px; @@ -121,6 +122,9 @@ .extension h3 { font-size: 1.5em; } + .extension > :last-child { + margin-bottom: 0; + } .extension-banner { position: relative; margin: -8px -8px 0 -8px; @@ -137,10 +141,13 @@ top: 0; left: 0; display: flex; + flex-wrap: wrap; width: 100%; height: 100%; align-items: center; + align-content: center; justify-content: center; + gap: 0.5rem; opacity: 0; transition: opacity .15s; background: rgba(0, 0, 0, 0.5); @@ -148,14 +155,12 @@ } .extension:hover .extension-buttons, .extension:focus-within .extension-buttons { opacity: 1; - } - .extension:hover .extension-buttons { backdrop-filter: blur(0.6px); } .extension-buttons > * { - padding: 8px; + padding: 0.5rem; background-color: #4c97ff; - border-radius: 8px; + border-radius: 0.5rem; border: none; font: inherit; cursor: pointer; @@ -175,17 +180,52 @@ .extension-buttons *:disabled { opacity: 0.5; } - .extension-buttons .copy { - margin: 0 8px 0 0; - } .extension-buttons .open { background-color: #ff4c4c; color: white; } + .extension-buttons .docs { + background-color: #FFAB19; + color: white; + } + .extension-buttons .sample { + background-color: #40BF4A; + color: white; + } .extension-buttons :disabled { opacity: 0.5; } + .sample-list { + display: none; + position: absolute; + left: 0.5rem; + right: 0.5rem; + width: calc(100% - 1rem); + margin-top: -1.5rem; + padding: 0.5rem; + box-sizing: border-box; + background-color: white; + border-radius: 0.5rem; + box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, .3); + border: 1px solid rgba(0, 0, 0, 0.15); + flex-direction: column; + gap: 0.5rem; + } + .sample-list h3 { + font-size: 1rem; + margin: 0; + } + .extension:hover[data-samples-open="true"] .sample-list { + display: flex; + } + @media (prefers-color-scheme: dark) { + .sample-list { + border: 1px solid rgba(255, 255, 255, 0.15); + background-color: #333; + } + } + footer { opacity: 0.8; width: 100%; @@ -230,6 +270,7 @@
+
Some extensions may not work in TurboWarp Desktop.
For compatibility, security, and offline support, each TurboWarp Desktop update contains an offline copy of these extensions from its release date, so some extensions may be outdated or missing. Use the latest update for best results.
@@ -273,6 +314,11 @@ e.target.focus(); } } + + if (e.target.className.includes('sample-list-button')) { + var extension = e.target.closest('.extension'); + extension.dataset.samplesOpen = extension.dataset.samplesOpen !== 'true'; + } }); @@ -306,8 +352,27 @@
Open Extension + + <% if (metadata.hasDocumentation) { %> + Documentation + <% } %> + + <% if (metadata.samples.length === 1) { %> + Sample Project + <% } else if (metadata.samples.length > 1) { %> + + <% } %>
+ + <% if (metadata.samples.length > 1) { %> +
+

<%= metadata.name %> Sample Projects:

+ <% for (const sample of metadata.samples) { %> + <%= sample.getTitle() %> + <% } %> +
+ <% } %>

<%= metadata.name %>

diff --git a/development/parse-extension-metadata.js b/development/parse-extension-metadata.js index 632f01216c..935a322bcc 100644 --- a/development/parse-extension-metadata.js +++ b/development/parse-extension-metadata.js @@ -24,6 +24,7 @@ class Extension { this.by = []; /** @type {Person[]} */ this.original = []; + this.context = ""; } } @@ -94,6 +95,9 @@ const parseMetadata = (extensionCode) => { case "original": metadata.original.push(parsePerson(value)); break; + case "context": + metadata.context = value; + break; default: // TODO break; diff --git a/development/parse-extension-translations.js b/development/parse-extension-translations.js new file mode 100644 index 0000000000..55e2570ff7 --- /dev/null +++ b/development/parse-extension-translations.js @@ -0,0 +1,123 @@ +const espree = require("espree"); +const esquery = require("esquery"); +const parseMetadata = require("./parse-extension-metadata"); + +/** + * @fileoverview Parses extension code to find calls to Scratch.translate() and statically + * evaluate its arguments. + */ + +const evaluateAST = (node) => { + if (node.type == "Literal") { + return node.value; + } + + if (node.type === "ObjectExpression") { + const object = {}; + for (const { key, value } of node.properties) { + // Normally Identifier refers to a variable, but inside of key we treat it as a string. + let evaluatedKey; + if (key.type === "Identifier") { + evaluatedKey = key.name; + } else { + evaluatedKey = evaluateAST(key); + } + + object[evaluatedKey] = evaluateAST(value); + } + return object; + } + + console.error(`Can't evaluate node:`, node); + throw new Error(`Can't evaluate ${node.type} node at build-time`); +}; + +/** + * Generate default ID for a translation that has no explicit ID. + * @param {string} string + * @returns {string} + */ +const defaultIdForString = (string) => { + // hardcoded in VM + return `_${string}`; +}; + +/** + * @param {string} js + * @returns {Record} + */ +const parseTranslations = (js) => { + const metadata = parseMetadata(js); + if (!metadata.name) { + throw new Error(`Extension needs a // Name: to generate translations`); + } + + let defaultDescription = `Part of the '${metadata.name}' extension.`; + if (metadata.context) { + defaultDescription += ` ${metadata.context}`; + } + + const ast = espree.parse(js, { + ecmaVersion: 2022, + }); + const selector = esquery.parse( + 'CallExpression[callee.object.name="Scratch"][callee.property.name="translate"]' + ); + const matches = esquery.match(ast, selector); + + const result = {}; + for (const match of matches) { + const args = match.arguments; + if (args.length === 0) { + throw new Error(`Scratch.translate() needs at least 1 argument`); + } + + const evaluated = evaluateAST(args[0]); + + let id; + let english; + let description; + + if (typeof evaluated === "string") { + id = defaultIdForString(evaluated); + english = evaluated; + description = defaultDescription; + } else if (typeof evaluated === "object" && evaluated !== null) { + english = evaluated.default; + id = evaluated.id || defaultIdForString(english); + + description = [defaultDescription, evaluated.description] + .filter((i) => i) + .join(" "); + } else { + throw new Error( + `Not a valid argument for Scratch.translate(): ${evaluated}` + ); + } + + if (typeof id !== "string") { + throw new Error( + `Scratch.translate() passed a value for id that is not a string: ${id}` + ); + } + if (typeof english !== "string") { + throw new Error( + `Scratch.translate() passed a value for default that is not a string: ${english}` + ); + } + if (typeof description !== "string") { + throw new Error( + `Scratch.translate() passed a value for description that is not a string: ${description}` + ); + } + + result[id] = { + string: english, + developer_comment: description, + }; + } + + return result; +}; + +module.exports = parseTranslations; diff --git a/development/server.js b/development/server.js index 875d6f6b8f..0809389d35 100644 --- a/development/server.js +++ b/development/server.js @@ -20,9 +20,6 @@ app.use((req, res, next) => { // Prevent browser from trying to guess file types. res.setHeader("X-Content-Type-Options", "nosniff"); - // We don't want this site to be embedded in frames. - res.setHeader("X-Frame-Options", "DENY"); - // No need to leak Referer headers. res.setHeader("Referrer-Policy", "no-referrer"); @@ -47,7 +44,7 @@ app.get("/*", (req, res, next) => { return; } - const fileInBuild = mostRecentBuild.getFile(req.path); + const fileInBuild = mostRecentBuild.getFile(decodeURIComponent(req.path)); if (!fileInBuild) { return next(); } diff --git a/docs/CST1229/zip.md b/docs/CST1229/zip.md index ef32c21961..bed28a8ea9 100644 --- a/docs/CST1229/zip.md +++ b/docs/CST1229/zip.md @@ -134,7 +134,7 @@ The type can be one of the following: - **hex**: A sequence of hexadecimal bytes (like `101A1B1C`), without a separator. - **binary**: A sequence of binary bytes (like `000000010010101001101011`), without a separator. -## File Info Blocks +## File info blocks Blocks for getting and setting additional information on a file. @@ -197,7 +197,7 @@ Returns a list of files in a directory, as JSON (which you can parse with the JS --- ```scratch -current directory path :: #a49a3a +(current directory path :: #a49a3a) ``` Returns the absolute path to the current directory. @@ -215,7 +215,7 @@ Sets the archive's comment to some text. Just like file comments, this is saved --- ```scratch -archive comment :: #a49a3a +(archive comment :: #a49a3a) ``` Returns the archive's comment. diff --git a/docs/CubesterYT/WindowControls.md b/docs/CubesterYT/WindowControls.md new file mode 100644 index 0000000000..b0efe57279 --- /dev/null +++ b/docs/CubesterYT/WindowControls.md @@ -0,0 +1,463 @@ +# Window Controls + +This extension provides a set of blocks that gives you greater control over the Program Window. + +Note: Most of these blocks only work in Electron, Pop Ups/Web Apps containing HTML packaged projects, and normal Web Apps. + +Examples include, but are not limited to: TurboWarp Desktop App, TurboWarp Web App, Pop Up/Web App windows that contain the HTML packaged project, and plain Electron. + +Blocks that still work outside of these will be specified. + +


+ +

Move Window Block (#)

+ +```scratch +move window to x: (0) y: (0) :: #359ed4 +``` + +Moves the Program Window to the defined "x" and "y" coordinate on the screen. + +
+ +

Move Window to Preset Block (#)

+ +Moves the Program Window to a preset. + +The menu area has ten options, ("center", "right", "left", "top", "bottom", "top right", "top left", "bottom right", "bottom left", "random position") + +#### Center + +```scratch +move window to the (center v) :: #359ed4 +``` + +When choosing "center", it will move the Program Window to the center of the screen. + +#### Right + +```scratch +move window to the (right v) :: #359ed4 +``` + +When choosing "right", it will move the Program Window to the right of the screen. + +#### Left + +```scratch +move window to the (left v) :: #359ed4 +``` + +When choosing "left", it will move the Program Window to the left of the screen. + +#### Top + +```scratch +move window to the (top v) :: #359ed4 +``` + +When choosing "top", it will move the Program Window to the top of the screen. + +#### Bottom + +```scratch +move window to the (bottom v) :: #359ed4 +``` + +When choosing "bottom", it will move the Program Window to the bottom of the screen. + +#### Top Right + +```scratch +move window to the (top right v) :: #359ed4 +``` + +When choosing "top right", it will move the Program Window to the top right of the screen. + +#### Top Left + +```scratch +move window to the (top left v) :: #359ed4 +``` + +When choosing "top left", it will move the Program Window to the top left of the screen. + +#### Bottom Right + +```scratch +move window to the (bottom right v) :: #359ed4 +``` + +When choosing "bottom right", it will move the Program Window to the bottom right of the screen. + +#### Bottom Left + +```scratch +move window to the (bottom left v) :: #359ed4 +``` + +When choosing "bottom left", it will move the Program Window to the bottom left of the screen. + +#### Random Position + +```scratch +move window to the (random position v) :: #359ed4 +``` + +When choosing "random position", it will move the Program Window to a random position on the screen. + +
+ +

Change "x" Block (#)

+ +```scratch +change window x by (50) :: #359ed4 +``` + +Dynamically changes the "x" position of the Program Window on the screen. + +
+ +

Set "x" Block (#)

+ +```scratch +set window x to (100) :: #359ed4 +``` + +Statically changes the "x" position of the Program Window on the screen. + +
+ +

Change "y" Block (#)

+ +```scratch +change window y by (50) :: #359ed4 +``` + +Dynamically changes the "y" position of the Program Window on the screen. + +
+ +

Set "y" Block (#)

+ +```scratch +set window y to (100) :: #359ed4 +``` + +Statically changes the "y" position of the Program Window on the screen. + +
+ +

Window "x" Reporter (#)

+ +```scratch +(window x :: #359ed4) +``` + +This reporter returns the "x" position of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Window "y" Reporter (#)

+ +```scratch +(window y :: #359ed4) +``` + +This reporter returns the "y" position of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Resize Window Block (#)

+ +```scratch +resize window to width: (1000) height: (1000) :: #359ed4 +``` + +Resizes the Program Window to the defined width and height values. + +
+ +

Resize Window Preset Block (#)

+ +Resizes the Program Window to a preset. + +The menu area has eight options, ("480x360", "640x480", "1280x720", "1920x1080", "2560x1440", "2048x1080", "3840x2160", "7680x4320") + +#### 480x360 + +```scratch +resize window to (480x360 v) :: #359ed4 +``` + +When choosing "480x360", it will resize the Program Window to 480x360 (360p). The aspect ratio for this size is 4:3. + +#### 640x480 + +```scratch +resize window to (640x480 v) :: #359ed4 +``` + +When choosing "640x480", it will resize the Program Window to 640x480 (480p). The aspect ratio for this size is 4:3. + +#### 1280x720 + +```scratch +resize window to (1280x720 v) :: #359ed4 +``` + +When choosing "1280x720", it will resize the Program Window to 1280x720 (720p). The aspect ratio for this size is 16:9. + +#### 1920x1080 + +```scratch +resize window to (1920x1080 v) :: #359ed4 +``` + +When choosing "1920x1080", it will resize the Program Window to 1920x1080 (1080p). The aspect ratio for this size is 16:9. + +#### 2560x1440 + +```scratch +resize window to (2560x1440 v) :: #359ed4 +``` + +When choosing "2560x1440", it will resize the Program Window to 2560x1440 (1440p). The aspect ratio for this size is 16:9. + +#### 2048x1080 + +```scratch +resize window to (2048x1080 v) :: #359ed4 +``` + +When choosing "2048x1080", it will resize the Program Window to 2048x1080 (2K/1080p[Higher Pixel Rate]). The aspect ratio for this size is 1:1.77. + +#### 3840x2160 + +```scratch +resize window to (3840x2160 v) :: #359ed4 +``` + +When choosing "3840x2160", it will resize the Program Window to 3840x2160 (4K). The aspect ratio for this size is 1:1.9. + +#### 7680x4320 + +```scratch +resize window to (7680x4320 v) :: #359ed4 +``` + +When choosing "7680x4320", it will resize the Program Window to 7680x4320 (8K). The aspect ratio for this size is 16:9. + +
+ +

Change Width Block (#)

+ +```scratch +change window width by (50) :: #359ed4 +``` + +Dynamically changes the width of the Program Window. + +
+ +

Set Width Block (#)

+ +```scratch +set window width to (1000) :: #359ed4 +``` + +Statically changes the width of the Program Window. + +
+ +

Change Height Block (#)

+ +```scratch +change window height by (50) :: #359ed4 +``` + +Dynamically changes the height of the Program Window. + +
+ +

Set Height Block (#)

+ +```scratch +set window height to (1000) :: #359ed4 +``` + +Statically changes the height of the Program Window. + +
+ +

Match Stage Size Block (#)

+ +```scratch +match stage size :: #359ed4 +``` + +Resizes the Program Window to match the aspect ratio of the stage. Works best when the stage is dynamically changed. + +Example: When using runtime options to change the stage size, using this block can help you adapt to the new stage size. + +Try this example script in a packaged project: + +```scratch +when green flag clicked +wait (1) seconds +set stage size width: (360) height: (480) :: #8c9abf +match stage size :: #359ed4 +move window to the (center v) :: #359ed4 +wait (1) seconds +set stage size width: (480) height: (360) :: #8c9abf +match stage size :: #359ed4 +move window to the (center v) :: #359ed4 +``` + +
+ +

Window Width Reporter (#)

+ +```scratch +(window width :: #359ed4) +``` + +This reporter returns the width of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Window Height Reporter (#)

+ +```scratch +(window height :: #359ed4) +``` + +This reporter returns the height of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Is Window Touching Screen Edge Boolean (#)

+ +```scratch + +``` + +This boolean returns true or false for whether or not the Program Window is touching the screen's edge. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Screen Width Reporter (#)

+ +```scratch +(screen width :: #359ed4) +``` + +This reporter returns the width of the Screen. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Screen Height Reporter (#)

+ +```scratch +(screen height :: #359ed4) +``` + +This reporter returns the height of the Screen. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Is Window Focused Boolean (#)

+ +```scratch + +``` + +This boolean returns true or false for whether or not the Program Window is in focus. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Set Window Title Block (#)

+ +```scratch +set window title to ["Hello World!] :: #359ed4 +``` + +Changes the title of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Window Title Reporter (#)

+ +```scratch +(window title :: #359ed4) +``` + +This reporter returns the title of the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Enter Fullscreen Block (#)

+ +```scratch +enter fullscreen :: #359ed4 +``` + +Makes the Program Window enter Fullscreen. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Exit Fullscreen Block (#)

+ +```scratch +exit fullscreen :: #359ed4 +``` + +Makes the Program Window exit Fullscreen. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Is Window Fullscreen Boolean (#)

+ +```scratch + +``` + +This boolean returns true or false for whether or not the Program Window is in fullscreen. + +This is supported outside of Electron, Pop Ups, and Web Apps. + +
+ +

Close Window Block (#)

+ +```scratch +close window :: cap :: #359ed4 +``` + +Closes the Program Window. + +This is supported outside of Electron, Pop Ups, and Web Apps. \ No newline at end of file diff --git a/docs/TheShovel/ShovelUtils.md b/docs/TheShovel/ShovelUtils.md new file mode 100644 index 0000000000..43a1cbef11 --- /dev/null +++ b/docs/TheShovel/ShovelUtils.md @@ -0,0 +1,105 @@ +# ShovelUtils + +Shovel Utils is an extension focused mostly on injecting and modifying sprites and assets inside the project, as well as several other functions. + +**Disclaimer: Modifying and importing assets can be dangerous, and has the potential to corrupt your project. Be careful!** + +## Importing Assets + +Shovel Utils offers an easy way to import several types of assets, including sprites, costumes, sounds, extensions, and even full projects. + +--- + +**This goes for all blocks that fetch from a link: If you're experiences errors and are not able to import an asset from a link, check your console! You may be running into a CORS error. To resolve this, use a proxy like [corsproxy.io](https://corsproxy.io).** + +```scratch +Import sprite from [Link or data uri here] +``` + +Imports a sprite into the project using a DataURI or link. Be mindful of sprite names; having two or more sprites with the same name can cause issues. + +```scratch +Import image from [https://extensions.turbowarp.org/dango.png] name [Dango] +``` + +Imports a costume from a PNG, Bitmap, or JPEG. **Does not work with SVGS**. The costume imports into the current sprite/backdrop the user has selected. + +```scratch +Import sound from [https://extensions.turbowarp.org/meow.mp3] name [Meow] +``` + +Imports a sound from any Scratch-compatible sound file. The sound imports into the current sprite/backdrop the user has selected. + +```scratch +Import project from [https://extensions.turbowarp.org/samples/Box2D.sb3] +``` + +Imports a full project from a link. This project will completely replace the contents of the current one. If the project is unsandboxed, it will ask permission before swapping contents. + +```scratch +Load extension from [https://extensions.turbowarp.org/utilities.js] +``` + +Imports any extension from a link. Extensions from the [Extension Gallery](https://extensions.turbowarp.org) can run unsandboxed, and don't require permission to import. + +## Other Ways to Modify The Project + +Aside from importing assets, Shovel Utils provides multiple miscellaneous features to modify and straight up delete parts of your projects. + +```scratch +Set editing target to [Sprite1] +``` + +Sets the selected sprite in the editor. You can also set your input to "Stage" to set the selected target to the backdrop. This does work packaged, however will not have a visual effect. + +```scratch +(get all sprites ::) +``` + +Gets the names of all the sprites (and the stage) as a JSON array. This can then be parsed using the JSON Extension. + +```scratch +Restart project +``` + +Emulates a green flag click on a project, even if the green flag isn't present. + +```scratch +Delete costume [costume1] in [Sprite1] +``` + +Deletes a costume from the specified sprite. If the costume doesn't exist, the block simply doesn't do anything. + +```scratch +Delete sprite [Sprite1] +``` + +Deletes a the specified sprite. If the user has the "Sprite Deletion Confirmation" addon enabled and the project is unpackaged, it will ask permission before deleting sprites. + +## Miscellaneous Features + +Aside from project modification, there's several utility blocks present in Shovel Utils. + +```scratch +(fps::) +``` + +Get the accurate FPS, or frames per second, of the current project. This is *not* the same as the "framerate limit" block from Runtime Options, as the block in Shovel Utils accounts for lag. + +```scratch +(Get list [MyList]) +``` + +Get the values of a list, exported as a JSON array. If the specified list has not been created yet, or is empty, the block will return empty. + +```scratch +Set list [MyList] to [⟦1,2⟧] +``` + +Sets the values of lists. Accepts JSON arrays as inputs. If the specified list has not been created yet, the block simply doesn't do anything. + +```scratch +(Get brightness of [ #ffffff] ::) +``` + +Gets the brightness of a hex value. Reports a whole number between 0 and 255. To transfer this to a value between 0 and 100 (what TurboWarp uses), divide the output of the block by 2.55 and round. diff --git a/docs/ar.md b/docs/ar.md index 2946f5c96d..0c5db4f2f9 100644 --- a/docs/ar.md +++ b/docs/ar.md @@ -54,7 +54,7 @@ Tells if AR is supported on this device. ``` Tells if AR engine knows what the current camera position and orientation is. -After entering AR mode, is is not immediately availible as the map of the environment needs to be built first. After enough information about environment has been gathered and processed, it becomes availible. It can temporarily become unavailible due to lack of detailed features in the view of camera that are used for motion tracking, fast motion causing camera image to become too blurry or camera getting covered. +After entering AR mode, is is not immediately available as the map of the environment needs to be built first. After enough information about environment has been gathered and processed, it becomes available. It can temporarily become unavailable due to lack of detailed features in the view of camera that are used for motion tracking, fast motion causing camera image to become too blurry or camera getting covered. --- @@ -63,7 +63,7 @@ After entering AR mode, is is not immediately availible as the map of the enviro ``` Tells if AR engine knows where the point of ray intersection is. -Can become unavailible for the same reasons as [is [pose] availible?] +Can become unavailable for the same reasons as [is [pose] available?] --- diff --git a/docs/gamejolt.md b/docs/gamejolt.md new file mode 100644 index 0000000000..df27bf184e --- /dev/null +++ b/docs/gamejolt.md @@ -0,0 +1,502 @@ +# Game Jolt API +This extension allows you to easily implement the Game Jolt API using a public domain library. +## Blocks +Blocks that the extension uses to send requests to the Game Jolt API. + +```scratch + +``` +Checks to see if the URL is the Game Jolt website. +### Session Blocks +Operating on the game's session. + +```scratch +Set game ID to (0) and private key [private key] :: #2F7F6F +``` +This block is required for all requests to work. + +--- +```scratch +[Open v] session :: #2F7F6F +``` +Opens/closes a game session. +- You must ping the session to keep it open and you must close it when you're done with it. + +When you login the session is opened automatically. + +--- +```scratch +Ping session :: #2F7F6F +``` +Pings an open session. +- If the session hasn't been pinged within 120 seconds, the system will close the session and you will have to open another one. +- It's recommended that you ping about every 30 seconds or so to keep the system from clearing out your session. + +When the session is opened it is pinged every 30 seconds automatically. +- You can ping it manually to update the session status. + +--- +```scratch +Set session status to [active v] :: #2F7F6F +``` +Sets the session status to active/idle. +- Ping the session to update it's status. + +--- +```scratch + +``` +Checks to see if there is an open session for the user. +- Can be used to see if a particular user account is active in the game. +### User Blocks +Login, logout and fetch users. + +```scratch +Login with [username] and [private token] :: #2F7F6F +``` +This block is required for all user based requests to work. + +Requires to not be logged in. +- When logged in on the Game Jolt website and the game is played on Game Jolt, the user is logged in automatically. + +--- +```scratch +Login automatically :: #2F7F6F +``` +Does automatic login after logout. + +Requires to not be logged in. +- Requires to be logged in on the Game Jolt website and for the game to be played on Game Jolt. + +--- +```scratch +Autologin available? :: #2F7F6F +``` +Checks to see if the user is logged in on the Game Jolt website and the game is played on Game Jolt. + +--- +```scratch +Logout :: #2F7F6F +``` +Logs out the user, the game session is then closed. + +Requires to be logged in. + +--- +```scratch + +``` +Checks to see if the user is logged in. + +--- +```scratch +Logged in user's username :: #2F7F6F +``` +Returns the logged in user's username. + +Requires to be logged in. + +--- +```scratch +Fetch user's [username] by [username v] :: #2F7F6F +``` +Fetches user data based on the user's username or the ID + +--- +```scratch +Fetch logged in user :: #2F7F6F +``` +Fetches logged in user data. + +Requires to be logged in. + +--- +```scratch +(Fetched user's [ID v] :: #2F7F6F) +``` +Returns fetched user's data by passed key. + +--- +```scratch +(Fetched user's data in JSON :: #2F7F6F) +``` +Returns fetched user's data in JSON. + +--- +```scratch +Fetch user's friend IDs :: #2F7F6F +``` +Fetches user's friend IDs. + +Requires to be logged in. + +--- +```scratch +(Fetched user's friend ID at index (0) :: #2F7F6F) +``` +Returns fetched user's friend ID at passed index. + +--- +```scratch +(Fetched user's friend IDs in JSON :: #2F7F6F) +``` +Returns fetched user's friend IDs in JSON. +### Trophy Blocks +Achieve, remove and fetch trophies. + +```scratch +Achieve trophy of ID (0) :: #2F7F6F +``` +Achieves trophy of passed ID. + +Requires to be logged in and for the trophy to not be achieved. + +--- +```scratch +Remove trophy of ID (0) :: #2F7F6F +``` +Removes trophy of passed ID. + +Requires to be logged in and for the trophy to be achieved. + +--- +```scratch +Fetch trophy of ID (0) :: #2F7F6F +``` +Fetches trophy of passed ID. + +Requires to be logged in. + +--- +```scratch +Fetch [all v] trophies :: #2F7F6F +``` +Fetches game trophies: +- All - fetches all trophies. +- All achieved - fetches all achieved trophies. +- All unachieved - fetches all unachieved trophies. + +Requires to be logged in. + +--- +```scratch +(Fetched trophy [ID v] at index (0) :: #2F7F6F) +``` +Returns fetched trophy data at passed index by passed key. + +--- +```scratch +(Fetched trophies in JSON :: #2F7F6F) +``` +Returns fetched trophy data in JSON +### Score Blocks +```scratch +Add score (1) in table of ID (0) with text [1 point] and comment [optional] :: #2F7F6F +``` +Adds a score in table of an ID with a text and an optional comment. +- Score, table ID, text and optional comment are passed. + +Requires to be logged in. + +--- +```scratch +Add [guest] score (1) in table of ID (0) with text [1 point] and comment [optional] :: #2F7F6F +``` +Adds a score in table of an ID with text and optional comment for the a guest. +- Score, table ID, text, optional comment and guest's username are passed. + +--- +```scratch +Fetch (1) [global v] score/s in table of ID (0) :: #2F7F6F +``` +Fetches global/user scores in table of an ID. +- Limit, global/user option and table ID are passed. + +Requires to be logged in. + +--- +```scratch +Fetch (1) [global v] score/s [better v] than (1) in table of ID (0) :: #2F7F6F +``` +Fetches global/user scores better/worse than a value in table of an ID. +- Limit, global/user option, better/worse option, a value and table ID are passed. + +Requires to be logged in. + +--- +```scratch +Fetch (1) [guest] score/s in table of ID (0) :: #2F7F6F +``` +Fetches guest's scores in table of an ID. +- Limit, guest's username and table ID are passed. + +--- +```scratch +Fetch (1) [guest] score/s [better v] than (1) in table of ID (0) :: #2F7F6F +``` +Fetched quest's scores better/worse than a value in table of an ID. +- Limit, guest's username, better/worse option, a value and a table ID are passed. + +--- +```scratch +(Fetched score [value v] at index (0) :: #2F7F6F) +``` +Returns fetched score data at passed index by passed key. + +--- +```scratch +(Fetched score data in JSON :: #2F7F6F) +``` +Returns fetched score data in JSON. + +--- +```scratch +(Fetched rank of (1) in table of ID (0) :: #2F7F6F) +``` +Fetches and returns a rank of passed value in table of passed ID. + +--- +```scratch +Fetch score tables :: #2F7F6F +``` +Fetches score tables. + +--- +```scratch +(Fetched table [ID v] at index (0) :: #2F7F6F) +``` +Returns fetched table data at passed index by passed key. + +--- +```scratch +(Fetched tables in JSON :: #2F7F6F) +``` +Returns fetched tables in JSON. +### Data Storage Blocks +Operate on Game Jolt's cloud variables. + +```scratch +Set [global v] data at [key] to [data] :: #2F7F6F +``` +Sets global/user data at passed key to passed data. + +User option requires to be logged in. + +--- +```scratch +(Fetched [global v] data at [key] :: #2F7F6F) +``` +Fetches and returns global/user data at passed key. + +User option requires to be logged in. + +--- +```scratch +Update [global v] data at [key] by [adding v](1) :: #2F7F6F +``` +Updates global/user data at key by operation with value. +- Global/user option, key, operation and value are passed. + +User option requires to be logged in. + +--- +```scratch +Remove [global v] data at [key] :: #2F7F6F +``` +Removes global/user data at passed key. + +User option requires to be logged in. + +--- +```scratch +Fetch all [global v] keys :: #2F7F6F +``` +Fetches all global/user keys. + +User option requires to be logged in. + +--- +```scratch +Fetch [global v] keys matching with [*] :: #2F7F6F +``` +Fetches global/user keys matching with passed pattern. +- Examples: + - A pattern of `*` matches all keys. + - A pattern of `key*` matches all keys with `key` at the start. + - A pattern of `*key` matches all keys with `key` at the end. + - A pattern of `*key*` matches all keys containing `key`. + +User option requires to be logged in. + +--- +```scratch +(Fetched key at index (0) :: #2F7F6F) +``` +Returns fetched key at passed index. + +--- +```scratch +(Fetched keys in JSON :: #2F7F6F) +``` +Returns fetched keys in JSON. +### Time Blocks +Track server's time. + +```scratch +Fetch server's time :: #2F7F6F +``` +Fetches server's time. + +--- +```scratch +(Fetched server's [timestamp v] :: #2F7F6F) +``` +Returns fetched server's time data by passed key. + +--- +```scratch +(Fetched server's time in JSON :: #2F7F6F) +``` +Returns fetched server's time data in JSON. +### Batch Blocks +Fetch more data per request. + +```scratch +Add [data-store/set] request with [{"key":"key", "data":"data"}] to batch :: #2F7F6F +``` +Adds passed arguments to the batch. +- The batch is an array of sub requests consisting of the namespace and the parameters object. + +--- +```scratch +Clear batch :: #2F7F6F +``` +Clears the batch of all sub requests. + +--- +```scratch +(Batch in JSON :: #2F7F6F) +``` +Returns the batch in JSON. + +--- +```scratch +Fetch batch [sequentially v] :: #2F7F6F +``` +Fetches the batch. +- After the fetch the batch is not cleared. + +You can call the batch request in different ways: +- Sequentially - all sub requests are processed in sequence. +- Sequentially, break on error - all sub requests are processed in sequence, if an error in one of them occurs, the whole request will fail. +- In parallel - all sub requests are processed in parallel, this is the fastest way but the results may vary depending on which request finished first. + +User based sub requests require to be logged in. + +--- +```scratch +(Fetched batch data in JSON :: #2F7F6F) +``` +Returns fetched batch data in JSON. + +### Debug Blocks +Blocks used for debugging. + +```scratch +Turn debug mode [off v] :: #2F7F6F +``` +Turns debug mode on/off. +- When debug mode is off, instead of errors, reporters return an empty string and booleans return false. + +--- +```scratch + +``` +Checks to see if debug mode is on. + +--- +```scratch +(Last API error :: #2F7F6F) +``` +Returns the last API error. + +### Handling Common Errors +Handling commonly encountered errors. + +```scratch +// Error: The game ID you passed in does not point to a valid game. +``` +This error occurs when the game ID you set is invalid. +#### Handling +This error can be avoided by using this block: +```scratch +Set game ID to [0] and private key to [private key] :: #2F7F6F +``` +- Make sure the value matches your game's ID. + +--- +```scratch +// Error: The signature you entered for the request is invalid. +``` +This error occurs when the private key you set is invalid. +#### Handling +This error can be avoided by using this block: +```scratch +Set game ID to [0] and private key to [private key] :: #2F7F6F +``` +- Make sure the value matches your game's private key. + +--- +```scratch +// Error: No user logged in. +``` +This error occurs when no user is logged in. +- The most common cause is that the extension failed to recognize the user. +#### Handling +This error can be avoided with a manual login option. +```scratch +when flag clicked +if > then +ask [login or continue as guest?] and wait +if <(answer) = [login]> then +ask [enter your username] and wait +set [username v] to (answer) +ask [enter your private game token] and wait +set [private game token v] to (answer) +Login with (username :: variables) and (private game token) :: #2F7F6F +end +end +``` + +--- +```scratch +// Error: No such user with the credentials passed in could be found. +``` +This error occurs when manual login failed to recognize the user credentials you passed in. +- It can also occur with autologin when no user is recognized by the extension. +#### Handling +This error can be avoided by modifying the previous example to try again after a failed login attempt. +```scratch +when flag clicked +if > then +ask [login or continue as guest?] and wait +if <(answer) = [login]> then +repeat until +ask [enter your username] and wait +set [username v] to (answer) +ask [enter your private game token] and wait +set [private game token v] to (answer) +Login with (username :: variables) and (private game token) :: #2F7F6F +end +end +end +``` + +--- +```scratch +// Error: Data not found. +// Error: Data at such index not found. +``` +These errors occur when you are trying to access non-existent data. +- Make sure you have previously fetched the data you are trying to access. +- Make sure you have the right index as indexing starts at 0 instead of 1. diff --git a/docs/godslayerakp/ws.md b/docs/godslayerakp/ws.md new file mode 100644 index 0000000000..eb9600b70b --- /dev/null +++ b/docs/godslayerakp/ws.md @@ -0,0 +1,129 @@ +# WebSocket + +This extension lets you communicate directly with most [WebSocket](https://en.wikipedia.org/wiki/WebSocket) servers. This is the protocol that things like cloud variables and Cloudlink use. + +These are rather low level blocks. They let you establish the connection, but your project still needs to know what kinds of messages to send and how to read messages from the server. + +## Blocks + +```scratch +connect to [wss://...] :: #307eff +``` +You have to run this block before any of the other blocks can do anything. You need to provide a valid WebSocket URL. + +The URL should start with `ws://` or `wss://`. For security reasons, `ws://` URLs will usually only work if the WebSocket is running on your computer (for example, `ws://localhost:8000`). + +Something simple to play with is the echo server: `wss://echoserver.redman13.repl.co`. Any message you send to it, it'll send right back to you. + +Note that connections are **per sprite**. Each sprite (or clone) can connect to one server at a time. Multiple sprites can connect to the same or different servers as much as your computer allows, but note those will all be separate connections. + +--- + +```scratch +when connected :: hat #307eff +``` +
+ +```scratch + +``` +Connecting to the server can take some time. Use these blocks to know when the connection was successful. After this, you can start sending and receiving messages. + +When the connection is lost, any blocks under the hat will also be stopped. + +--- + +```scratch +when message received :: hat #307eff +``` +
+ +```scratch +(received message data :: #307eff) +``` + +These blocks let you receive messages from the server. The hat block block will run once for each message the server sends with the data stored in the round reporter block. + +Note that WebSocket supports two types of messages: + + - **Text messages**: The data in the block will just be the raw text from the server. + - **Binary messages**: The data in the block will be a base64-encoded data: URL of the data, as it may not be safe to store directly in a string. You can use other extensions to convert this to something useful, such as fetch, depending on what data it contains. + +If multiple messages are received in a single frame or if your message processing logic causes delays (for example, using wait blocks), messages after the first one will be placed in a **queue**. Once your script finishes, if there's anything in the queue, the "when message received" block will run again the next frame. + +--- + +```scratch +send message (...) :: #307eff +``` + +This is the other side: it lets you send messages to the server. Only text messages are supported; binary messages are not yet supported. + +There's no queue this time. The messages are sent over as fast as your internet connection and the server will allow. + +--- + +```scratch +when connection closes :: hat #307eff +``` +
+ +```scratch + +``` +
+ +These let you detect when either the server closes the connection or your project closes the connection. They don't distinguish. Note that connections have separate blocks. + +Servers can close connections for a lot of reasons: perhaps it's restarting, or perhaps your project tried to do something the server didn't like. + +```scratch +(closing code :: #307eff) +``` +
+ +```scratch +(closing message :: #307eff) +``` + +These blocks can help you gain some insight. Closing code is a number from the WebSocket protocol. There is a [big table](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) of possible values, but generally there is very little to gain from looking at these. + +Servers can also send a text "reason" when they close the connection, although almost no servers actually do this. + +```scratch +close connection :: #307eff +``` +
+ +```scratch +close connection with code (1000) :: #307eff +``` +
+ +```scratch +close connection with reason (...) and code (1000) :: #307eff +``` + +Your project can also close the connection whenever it wants. All of these blocks do basically the same thing. + +Just like how the server can send a code and a reason when it closes the connection, you can send those to the server. Note some limitations: + + - **Code** can be either the number 1000 ("Normal Closure") or an integer in the range 3000-4999 (meaning depends on what server you're talking to). Anything not in this range will be converted to 1000. Few servers will look at this. + - **Reason** can be any text up to 123-bytes long when encoded as UTF-8. Usually that just means up to 123 characters, but things like Emoji are technically multiple characters. Regardless very few servers will even bother to look at this. + +--- + +```scratch +when connection errors :: hat #307eff +``` +
+ +```scratch + +``` + +Sometimes things don't go so well. Maybe your internet connection died, the server is down, or you typed in the wrong URL. There's a lot of things that can go wrong. These let you try to handle that. + +Unfortunately we can't give much insight as to what caused the errors. Your browser tells us very little, but even if it did give us more information, it probably wouldn't be very helpful. + +A connection can either close or error; it won't do both. diff --git a/extensions/-SIPC-/consoles.js b/extensions/-SIPC-/consoles.js index 3454a518e1..e47a061681 100644 --- a/extensions/-SIPC-/consoles.js +++ b/extensions/-SIPC-/consoles.js @@ -1,6 +1,6 @@ // Name: Consoles // ID: sipcconsole -// Description: Blocks that interact the JavaScript console built in to your browser's developer tools. +// Description: Blocks that interact with the JavaScript console built in to your browser's developer tools. // By: -SIPC- (function (Scratch) { @@ -14,7 +14,7 @@ getInfo() { return { id: "sipcconsole", - name: "Consoles", + name: Scratch.translate("Consoles"), color1: "#808080", color2: "#8c8c8c", color3: "#999999", @@ -24,61 +24,61 @@ { opcode: "Emptying", blockType: Scratch.BlockType.COMMAND, - text: "Clear Console", + text: Scratch.translate("Clear Console"), arguments: {}, }, { opcode: "Information", blockType: Scratch.BlockType.COMMAND, - text: "Information [string]", + text: Scratch.translate("Information [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Information", + defaultValue: Scratch.translate("Information"), }, }, }, { opcode: "Journal", blockType: Scratch.BlockType.COMMAND, - text: "Journal [string]", + text: Scratch.translate("Journal [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Journal", + defaultValue: Scratch.translate("Journal"), }, }, }, { opcode: "Warning", blockType: Scratch.BlockType.COMMAND, - text: "Warning [string]", + text: Scratch.translate("Warning [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Warning", + defaultValue: Scratch.translate("Warning"), }, }, }, { opcode: "Error", blockType: Scratch.BlockType.COMMAND, - text: "Error [string]", + text: Scratch.translate("Error [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Error", + defaultValue: Scratch.translate("Error"), }, }, }, { opcode: "debug", blockType: Scratch.BlockType.COMMAND, - text: "Debug [string]", + text: Scratch.translate("Debug [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Debug", + defaultValue: Scratch.translate("Debug"), }, }, }, @@ -87,62 +87,66 @@ { opcode: "group", blockType: Scratch.BlockType.COMMAND, - text: "Create a group named [string]", + text: Scratch.translate("Create a group named [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "group", + defaultValue: Scratch.translate("group"), }, }, }, { opcode: "groupCollapsed", blockType: Scratch.BlockType.COMMAND, - text: "Create a collapsed group named [string]", + text: Scratch.translate("Create a collapsed group named [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "group", + defaultValue: Scratch.translate("group"), }, }, }, { opcode: "groupEnd", blockType: Scratch.BlockType.COMMAND, - text: "Exit the current group", + text: Scratch.translate("Exit the current group"), arguments: {}, }, "---", { opcode: "Timeron", blockType: Scratch.BlockType.COMMAND, - text: "Start a timer named [string]", + text: Scratch.translate("Start a timer named [string]"), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Time", + defaultValue: Scratch.translate("Time"), }, }, }, { opcode: "Timerlog", blockType: Scratch.BlockType.COMMAND, - text: "Print the time run by the timer named [string]", + text: Scratch.translate( + "Print the time run by the timer named [string]" + ), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Time", + defaultValue: Scratch.translate("Time"), }, }, }, { opcode: "Timeroff", blockType: Scratch.BlockType.COMMAND, - text: "End the timer named [string] and print the time elapsed from start to end", + text: Scratch.translate( + "End the timer named [string] and print the time elapsed from start to end" + ), arguments: { string: { type: Scratch.ArgumentType.STRING, - defaultValue: "Time", + defaultValue: Scratch.translate("Time"), }, }, }, diff --git a/extensions/-SIPC-/recording.js b/extensions/-SIPC-/recording.js index 2265654c94..b4bc74f081 100644 --- a/extensions/-SIPC-/recording.js +++ b/extensions/-SIPC-/recording.js @@ -12,39 +12,45 @@ getInfo() { return { id: "sipcrecording", - name: "Recording", + name: Scratch.translate("Recording"), color1: "#696969", blocks: [ { opcode: "startRecording", blockType: Scratch.BlockType.COMMAND, - text: "Start recording", + text: Scratch.translate("Start recording"), blockIconURI: icon, arguments: {}, }, { opcode: "stopRecording", blockType: Scratch.BlockType.COMMAND, - text: "Stop recording", + text: Scratch.translate("Stop recording"), blockIconURI: icon, arguments: {}, }, { opcode: "stopRecordingAndDownload", blockType: Scratch.BlockType.COMMAND, - text: "Stop recording and download with [name] as filename", + text: Scratch.translate( + "Stop recording and download with [name] as filename" + ), blockIconURI: icon, arguments: { name: { type: Scratch.ArgumentType.STRING, - defaultValue: "recording.wav", + defaultValue: + Scratch.translate({ + default: "recording", + description: "Default file name", + }) + ".wav", }, }, }, { opcode: "isRecording", blockType: Scratch.BlockType.BOOLEAN, - text: "Recording?", + text: Scratch.translate("Recording?"), blockIconURI: icon, arguments: {}, }, diff --git a/extensions/-SIPC-/time.js b/extensions/-SIPC-/time.js index 6a8413c821..5690f799b9 100644 --- a/extensions/-SIPC-/time.js +++ b/extensions/-SIPC-/time.js @@ -13,7 +13,7 @@ getInfo() { return { id: "sipctime", - name: "Time", + name: Scratch.translate("Time"), color1: "#ff8000", color2: "#804000", color3: "#804000", @@ -23,19 +23,19 @@ { opcode: "Timestamp", blockType: Scratch.BlockType.REPORTER, - text: "current timestamp", + text: Scratch.translate("current timestamp"), arguments: {}, }, { opcode: "timezone", blockType: Scratch.BlockType.REPORTER, - text: "current time zone", + text: Scratch.translate("current time zone"), arguments: {}, }, { opcode: "Timedata", blockType: Scratch.BlockType.REPORTER, - text: "get [Timedata] from [timestamp]", + text: Scratch.translate("get [Timedata] from [timestamp]"), arguments: { timestamp: { type: Scratch.ArgumentType.NUMBER, @@ -51,7 +51,7 @@ { opcode: "TimestampToTime", blockType: Scratch.BlockType.REPORTER, - text: "convert [timestamp] to datetime", + text: Scratch.translate("convert [timestamp] to datetime"), arguments: { timestamp: { type: Scratch.ArgumentType.NUMBER, @@ -62,7 +62,7 @@ { opcode: "TimeToTimestamp", blockType: Scratch.BlockType.REPORTER, - text: "convert [time] to timestamp", + text: Scratch.translate("convert [time] to timestamp"), arguments: { time: { type: Scratch.ArgumentType.STRING, @@ -74,7 +74,32 @@ menus: { Time: { acceptReporters: true, - items: ["year", "month", "day", "hour", "minute", "second"], + items: [ + { + text: Scratch.translate("year"), + value: "year", + }, + { + text: Scratch.translate("month"), + value: "month", + }, + { + text: Scratch.translate("day"), + value: "day", + }, + { + text: Scratch.translate("hour"), + value: "hour", + }, + { + text: Scratch.translate("minute"), + value: "minute", + }, + { + text: Scratch.translate("second"), + value: "second", + }, + ], }, }, }; diff --git a/extensions/.eslintrc.js b/extensions/.eslintrc.js index 3fc982110c..115a0a81cd 100644 --- a/extensions/.eslintrc.js +++ b/extensions/.eslintrc.js @@ -84,6 +84,22 @@ module.exports = { { selector: 'Program > :not(ExpressionStatement[expression.type=CallExpression][expression.callee.type=/FunctionExpression/])', message: 'All extension code must be within (function (Scratch) { ... })(Scratch);' + }, + { + selector: 'CallExpression[callee.object.object.name=Scratch][callee.object.property.name=translate][callee.property.name=setup]', + message: 'Do not call Scratch.translate.setup() yourself. Just use Scratch.translate() and let the build script handle it.' + }, + { + selector: 'MethodDefinition[key.name=getInfo] Property[key.name=id][value.callee.property.name=translate]', + message: 'Do not translate extension ID' + }, + { + selector: 'MethodDefinition[key.name=docsURI] Property[key.name=id][value.callee.property.name=translate]', + message: 'Do not translate docsURI' + }, + { + selector: 'MethodDefinition[key.name=getInfo] Property[key.name=opcode][value.callee.property.name=translate]', + message: 'Do not translate block opcode' } ] } diff --git a/extensions/0832/rxFS2.js b/extensions/0832/rxFS2.js index 871ef0b1ca..0ba12451ef 100644 --- a/extensions/0832/rxFS2.js +++ b/extensions/0832/rxFS2.js @@ -15,51 +15,6 @@ (function (Scratch) { "use strict"; - Scratch.translate.setup({ - zh: { - start: "新建 [STR] ", - folder: "设置 [STR] 为 [STR2] ", - folder_default: "大主教大祭司主宰世界!", - sync: "将 [STR] 的位置更改为 [STR2] ", - del: "删除 [STR] ", - webin: "从网络加载 [STR]", - open: "打开 [STR]", - clean: "清空文件系统", - in: "从 [STR] 导入文件系统", - out: "导出文件系统", - list: "列出 [STR] 下的所有文件", - search: "搜索 [STR]", - }, - ru: { - start: "Создать [STR]", - folder: "Установить [STR] в [STR2]", - folder_default: "Архиепископ Верховный жрец Правитель мира!", - sync: "Изменить расположение [STR] на [STR2]", - del: "Удалить [STR]", - webin: "Загрузить [STR] из Интернета", - open: "Открыть [STR]", - clean: "Очистить файловую систему", - in: "Импортировать файловую систему из [STR]", - out: "Экспортировать файловую систему", - list: "Список всех файлов в [STR]", - search: "Поиск [STR]", - }, - jp: { - start: "新規作成 [STR]", - folder: "[STR] を [STR2] に設定する", - folder_default: "大主教大祭司世界の支配者!", - sync: "[STR] の位置を [STR2] に変更する", - del: "[STR] を削除する", - webin: "[STR] をウェブから読み込む", - open: "[STR] を開く", - clean: "ファイルシステムをクリアする", - in: "[STR] からファイルシステムをインポートする", - out: "ファイルシステムをエクスポートする", - list: "[STR] にあるすべてのファイルをリストする", - search: "[STR] を検索する", - }, - }); - var rxFSfi = new Array(); var rxFSsy = new Array(); var Search, i, str, str2; diff --git a/extensions/Alestore/nfcwarp.js b/extensions/Alestore/nfcwarp.js index 6e1face62c..e2d29b21b7 100644 --- a/extensions/Alestore/nfcwarp.js +++ b/extensions/Alestore/nfcwarp.js @@ -2,6 +2,7 @@ // ID: alestorenfc // Description: Allows reading data from NFC (NDEF) devices. Only works in Chrome on Android. // By: Alestore Games +// Context: NFC stands for "Near-field communication". Ideally check a real phone in your language to see how they translated it. (function (Scratch) { "use strict"; @@ -17,7 +18,7 @@ getInfo() { return { id: "alestorenfc", - name: "NFCWarp", + name: Scratch.translate("NFCWarp"), color1: "#FF4646", color2: "#FF0000", color3: "#990033", @@ -26,17 +27,17 @@ blocks: [ { blockType: Scratch.BlockType.LABEL, - text: "Only works in Chrome on Android", + text: Scratch.translate("Only works in Chrome on Android"), }, { opcode: "supported", blockType: Scratch.BlockType.BOOLEAN, - text: "NFC supported?", + text: Scratch.translate("NFC supported?"), }, { opcode: "nfcRead", blockType: Scratch.BlockType.REPORTER, - text: "read NFC tag", + text: Scratch.translate("read NFC tag"), disableMonitor: true, }, ], diff --git a/extensions/CST1229/images.js b/extensions/CST1229/images.js index 7524b64dea..43b33ccd0a 100644 --- a/extensions/CST1229/images.js +++ b/extensions/CST1229/images.js @@ -19,6 +19,10 @@ this.createdImages = new Set(); this.validImages = new Set(); + + Scratch.vm.runtime.on("RUNTIME_DISPOSED", () => { + this.deleteAllImages(); + }); } getInfo() { @@ -29,7 +33,7 @@ { opcode: "getImage", blockType: Scratch.BlockType.REPORTER, - text: "new image from URL [IMAGEURL]", + text: Scratch.translate("new image from URL [IMAGEURL]"), arguments: { IMAGEURL: { type: Scratch.ArgumentType.STRING, @@ -43,7 +47,7 @@ { opcode: "penTrailsImage", blockType: Scratch.BlockType.REPORTER, - text: "pen trails as image", + text: Scratch.translate("pen trails as image"), arguments: {}, hideFromPalette: true, }, @@ -51,7 +55,7 @@ { opcode: "queryImage", blockType: Scratch.BlockType.REPORTER, - text: "[QUERY] of image [IMG]", + text: Scratch.translate("[QUERY] of image [IMG]"), arguments: { QUERY: { type: Scratch.ArgumentType.STRING, @@ -71,7 +75,9 @@ { opcode: "drawImage", blockType: Scratch.BlockType.COMMAND, - text: "stamp image [IMG] at x: [X] y: [Y] x scale: [XSCALE] y scale: [YSCALE]", + text: Scratch.translate( + "stamp image [IMG] at x: [X] y: [Y] x scale: [XSCALE] y scale: [YSCALE]" + ), arguments: { IMG: { // Intentional null input to require dropping a block in @@ -100,7 +106,7 @@ { opcode: "switchToImage", blockType: Scratch.BlockType.COMMAND, - text: "switch costume to image [IMG]", + text: Scratch.translate("switch costume to image [IMG]"), arguments: { IMG: { // Intentional null input to require dropping a block in @@ -112,20 +118,20 @@ { opcode: "imageID", blockType: Scratch.BlockType.REPORTER, - text: "current image ID", + text: Scratch.translate("current image ID"), arguments: {}, disableMonitor: true, }, { opcode: "resetCostume", blockType: Scratch.BlockType.COMMAND, - text: "switch back to costume", + text: Scratch.translate("switch back to costume"), arguments: {}, }, { opcode: "deleteImage", blockType: Scratch.BlockType.COMMAND, - text: "delete image [IMG]", + text: Scratch.translate("delete image [IMG]"), arguments: { IMG: { type: null, @@ -136,33 +142,52 @@ { opcode: "deleteAllImages", blockType: Scratch.BlockType.COMMAND, - text: "delete all images", + text: Scratch.translate("delete all images"), arguments: {}, }, ], menus: { queryImage: { acceptReporters: false, - items: this._queryImageMenu(), + items: [ + { + text: Scratch.translate("width"), + value: QueryImage.WIDTH, + }, + { + text: Scratch.translate("height"), + value: QueryImage.HEIGHT, + }, + { + text: Scratch.translate("top"), + value: QueryImage.TOP, + }, + { + text: Scratch.translate("bottom"), + value: QueryImage.BOTTOM, + }, + { + text: Scratch.translate("left"), + value: QueryImage.LEFT, + }, + { + text: Scratch.translate("right"), + value: QueryImage.RIGHT, + }, + { + text: Scratch.translate("rotation center x"), + value: QueryImage.ROTATION_CENTER_X, + }, + { + text: Scratch.translate("rotation center y"), + value: QueryImage.ROTATION_CENTER_Y, + }, + ], }, }, }; } - _queryImageMenu() { - const get = (param) => QueryImage[param]; - return [ - get("WIDTH"), - get("HEIGHT"), - get("TOP"), - get("BOTTOM"), - get("LEFT"), - get("RIGHT"), - get("ROTATION_CENTER_X"), - get("ROTATION_CENTER_Y"), - ]; - } - _createdImage(id) { if (!this.render || id === undefined || !this.render._allSkins[id]) return ""; diff --git a/extensions/CST1229/zip.js b/extensions/CST1229/zip.js index a362fd17d7..a8827db750 100644 --- a/extensions/CST1229/zip.js +++ b/extensions/CST1229/zip.js @@ -18,12 +18,16 @@ // jszip has its own "go to directory" system, but it sucks // implement our own instead this.zipPath = null; + + Scratch.vm.runtime.on("RUNTIME_DISPOSED", () => { + this.close(); + }); } getInfo() { return { id: "cst1229zip", - name: "Zip", + name: Scratch.translate("Zip"), docsURI: "https://extensions.turbowarp.org/CST1229/zip", blockIconURI: extIcon, @@ -36,13 +40,13 @@ { opcode: "createEmpty", blockType: Scratch.BlockType.COMMAND, - text: "create empty archive", + text: Scratch.translate("create empty archive"), arguments: {}, }, { opcode: "open", blockType: Scratch.BlockType.COMMAND, - text: "open zip from [TYPE] [DATA]", + text: Scratch.translate("open zip from [TYPE] [DATA]"), arguments: { TYPE: { type: Scratch.ArgumentType.STRING, @@ -59,7 +63,9 @@ { opcode: "getZip", blockType: Scratch.BlockType.REPORTER, - text: "output zip type [TYPE] compression level [COMPRESSION]", + text: Scratch.translate( + "output zip type [TYPE] compression level [COMPRESSION]" + ), arguments: { TYPE: { type: Scratch.ArgumentType.STRING, @@ -76,13 +82,13 @@ { opcode: "close", blockType: Scratch.BlockType.COMMAND, - text: "close archive", + text: Scratch.translate("close archive"), arguments: {}, }, { opcode: "isOpen", blockType: Scratch.BlockType.BOOLEAN, - text: "archive is open?", + text: Scratch.translate("archive is open?"), arguments: {}, }, @@ -91,10 +97,11 @@ { opcode: "exists", blockType: Scratch.BlockType.BOOLEAN, - text: "[OBJECT] exists?", + text: Scratch.translate("[OBJECT] exists?"), arguments: { OBJECT: { type: Scratch.ArgumentType.STRING, + // Don't translate so this matches the default zip defaultValue: "folder/", }, }, @@ -102,11 +109,16 @@ { opcode: "writeFile", blockType: Scratch.BlockType.COMMAND, - text: "write file [FILE] content [CONTENT] type [TYPE]", + text: Scratch.translate( + "write file [FILE] content [CONTENT] type [TYPE]" + ), arguments: { FILE: { type: Scratch.ArgumentType.STRING, - defaultValue: "new file.txt", + defaultValue: `${Scratch.translate({ + default: "new file", + description: "Default file name", + })}.txt`, }, TYPE: { type: Scratch.ArgumentType.STRING, @@ -115,7 +127,7 @@ }, CONTENT: { type: Scratch.ArgumentType.STRING, - defaultValue: "Hello, world?", + defaultValue: Scratch.translate("Hello, world?"), }, }, }, @@ -126,10 +138,12 @@ arguments: { FROM: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "hello.txt", }, TO: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "hello renamed.txt", }, }, @@ -137,10 +151,11 @@ { opcode: "deleteFile", blockType: Scratch.BlockType.COMMAND, - text: "delete [FILE]", + text: Scratch.translate("delete [FILE]"), arguments: { FILE: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "hello.txt", }, }, @@ -148,10 +163,11 @@ { opcode: "getFile", blockType: Scratch.BlockType.REPORTER, - text: "file [FILE] as [TYPE]", + text: Scratch.translate("file [FILE] as [TYPE]"), arguments: { FILE: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "hello.txt", }, TYPE: { @@ -167,7 +183,7 @@ { opcode: "setFileMeta", blockType: Scratch.BlockType.COMMAND, - text: "set [META] of [FILE] to [VALUE]", + text: Scratch.translate("set [META] of [FILE] to [VALUE]"), arguments: { META: { type: Scratch.ArgumentType.STRING, @@ -176,6 +192,7 @@ }, FILE: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "folder/dango.png", }, VALUE: { @@ -187,7 +204,7 @@ { opcode: "getFileMeta", blockType: Scratch.BlockType.REPORTER, - text: "[META] of [FILE]", + text: Scratch.translate("[META] of [FILE]"), arguments: { META: { type: Scratch.ArgumentType.STRING, @@ -196,6 +213,7 @@ }, FILE: { type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip defaultValue: "folder/dango.png", }, }, @@ -206,18 +224,18 @@ { opcode: "createDir", blockType: Scratch.BlockType.COMMAND, - text: "create directory [DIR]", + text: Scratch.translate("create directory [DIR]"), arguments: { DIR: { type: Scratch.ArgumentType.STRING, - defaultValue: "new folder", + defaultValue: Scratch.translate("new folder"), }, }, }, { opcode: "goToDir", blockType: Scratch.BlockType.COMMAND, - text: "go to directory [DIR]", + text: Scratch.translate("go to directory [DIR]"), arguments: { DIR: { type: Scratch.ArgumentType.STRING, @@ -228,7 +246,7 @@ { opcode: "getDir", blockType: Scratch.BlockType.REPORTER, - text: "contents of directory [DIR]", + text: Scratch.translate("contents of directory [DIR]"), arguments: { DIR: { type: Scratch.ArgumentType.STRING, @@ -239,7 +257,7 @@ { opcode: "currentDir", blockType: Scratch.BlockType.REPORTER, - text: "current directory path", + text: Scratch.translate("current directory path"), }, "---", @@ -247,18 +265,18 @@ { opcode: "setComment", blockType: Scratch.BlockType.COMMAND, - text: "set archive comment to [COMMENT]", + text: Scratch.translate("set archive comment to [COMMENT]"), arguments: { COMMENT: { type: Scratch.ArgumentType.STRING, - defaultValue: "any text", + defaultValue: Scratch.translate("any text"), }, }, }, { opcode: "getComment", blockType: Scratch.BlockType.REPORTER, - text: "archive comment", + text: Scratch.translate("archive comment"), arguments: {}, }, @@ -267,7 +285,7 @@ { opcode: "normalizePath", blockType: Scratch.BlockType.REPORTER, - text: "path [PATH] from [ORIGIN]", + text: Scratch.translate("path [PATH] from [ORIGIN]"), arguments: { PATH: { type: Scratch.ArgumentType.STRING, @@ -284,28 +302,115 @@ fileType: { // used in the open zip block acceptReporters: true, - items: ["URL", "base64", "hex", "binary", "string"], + items: [ + { + text: Scratch.translate("URL"), + value: "URL", + }, + { + text: Scratch.translate("base64"), + value: "base64", + }, + { + text: Scratch.translate("hex"), + value: "hex", + }, + { + text: Scratch.translate("binary"), + value: "binary", + }, + { + text: Scratch.translate("string"), + value: "string", + }, + ], }, zipFileType: { // used in the output zip block acceptReporters: true, - items: ["data: URL", "base64", "hex", "binary", "string"], + items: [ + { + text: Scratch.translate("data: URL"), + value: "data: URL", + }, + { + text: Scratch.translate("base64"), + value: "base64", + }, + { + text: Scratch.translate("hex"), + value: "hex", + }, + { + text: Scratch.translate("binary"), + value: "binary", + }, + { + text: Scratch.translate("string"), + value: "string", + }, + ], }, getFileType: { // used in the get file block acceptReporters: true, - items: ["text", "data: URL", "base64", "hex", "binary"], + items: [ + { + text: Scratch.translate("text"), + value: "text", + }, + { + text: Scratch.translate("data: URL"), + value: "data: URL", + }, + { + text: Scratch.translate("base64"), + value: "base64", + }, + { + text: Scratch.translate("hex"), + value: "hex", + }, + { + text: Scratch.translate("binary"), + value: "binary", + }, + ], }, writeFileType: { // used in the write file block acceptReporters: true, - items: ["text", "URL", "base64", "hex", "binary"], + items: [ + { + text: Scratch.translate("text"), + value: "text", + }, + { + text: Scratch.translate("URL"), + value: "URL", + }, + { + text: Scratch.translate("base64"), + value: "base64", + }, + { + text: Scratch.translate("hex"), + value: "hex", + }, + { + text: Scratch.translate("binary"), + value: "binary", + }, + ], }, compressionLevel: { acceptReporters: true, items: [ - { text: "no compression (fastest)", value: "0" }, - { text: "1 (fast, large)", value: "1" }, + { + text: Scratch.translate("no compression (fastest)"), + value: "0", + }, + { text: Scratch.translate("1 (fast, large)"), value: "1" }, { text: "2", value: "2" }, { text: "3", value: "3" }, { text: "4", value: "4" }, @@ -313,28 +418,61 @@ { text: "6", value: "6" }, { text: "7", value: "7" }, { text: "8", value: "8" }, - { text: "9 (slowest, smallest)", value: "9" }, + { text: Scratch.translate("9 (slowest, smallest)"), value: "9" }, ], }, fileMeta: { acceptReporters: true, items: [ - "name", - "path", - "folder", - "modification date", - "long modification date", - "modified days since 2000", - "unix modified timestamp", - "comment", + { + text: Scratch.translate("name"), + value: "name", + }, + { + text: Scratch.translate("path"), + value: "path", + }, + { + text: Scratch.translate("folder"), + value: "folder", + }, + { + text: Scratch.translate("modification date"), + value: "modification date", + }, + { + text: Scratch.translate("long modification date"), + value: "long modification date", + }, + { + text: Scratch.translate("modified days since 2000"), + value: "modified days since 2000", + }, + { + text: Scratch.translate("unix modified timestamp"), + value: "unix modified timestamp", + }, + { + text: Scratch.translate("comment"), + value: "comment", + }, ], }, setFileMeta: { acceptReporters: true, items: [ - "modified days since 2000", - "unix modified timestamp", - "comment", + { + text: Scratch.translate("modified days since 2000"), + value: "modified days since 2000", + }, + { + text: Scratch.translate("unix modified timestamp"), + value: "unix modified timestamp", + }, + { + text: Scratch.translate("comment"), + value: "comment", + }, ], }, }, diff --git a/extensions/Clay/htmlEncode.js b/extensions/Clay/htmlEncode.js new file mode 100644 index 0000000000..ba93b6c902 --- /dev/null +++ b/extensions/Clay/htmlEncode.js @@ -0,0 +1,54 @@ +// Name: HTML Encode +// ID: clayhtmlencode +// Description: Escape untrusted text to safely include in HTML. +// By: ClaytonTDM + +(function (Scratch) { + "use strict"; + + class HtmlEncode { + getInfo() { + return { + id: "claytonhtmlencode", + name: Scratch.translate("HTML Encode"), + blocks: [ + { + opcode: "encode", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("encode [text] as HTML-safe"), + arguments: { + text: { + type: Scratch.ArgumentType.STRING, + // don't use a script tag as the example here as the closing script + // tag might break things when this extension gets inlined in packed + // projects + defaultValue: `

${Scratch.translate("Hello!")}

`, + }, + }, + }, + ], + }; + } + + encode({ text }) { + return Scratch.Cast.toString(text).replace(/["'&<>]/g, (a) => { + switch (a) { + case "&": + return "&"; + case '"': + return "'"; + case "'": + return """; + case ">": + return ">"; + case "<": + return "<"; + } + // this should never happen... + return ""; + }); + } + } + + Scratch.extensions.register(new HtmlEncode()); +})(Scratch); diff --git a/extensions/CubesterYT/TurboHook.js b/extensions/CubesterYT/TurboHook.js index a87d1192a4..7230166881 100644 --- a/extensions/CubesterYT/TurboHook.js +++ b/extensions/CubesterYT/TurboHook.js @@ -22,7 +22,7 @@ getInfo() { return { id: "cubesterTurboHook", - name: "TurboHook", + name: Scratch.translate("TurboHook"), color1: "#3c48c2", color2: "#2f39a1", color3: "#28318f", @@ -32,7 +32,9 @@ blocks: [ { opcode: "webhook", - text: "webhook data: [hookDATA] webhook url: [hookURL]", + text: Scratch.translate( + "webhook data: [hookDATA] webhook url: [hookURL]" + ), blockType: Scratch.BlockType.COMMAND, arguments: { hookURL: { @@ -63,7 +65,20 @@ menus: { PARAMS: { acceptReporters: true, - items: ["content", "name", "icon"], + items: [ + { + text: Scratch.translate("content"), + value: "content", + }, + { + text: Scratch.translate("name"), + value: "name", + }, + { + text: Scratch.translate("icon"), + value: "icon", + }, + ], }, }, }; diff --git a/extensions/CubesterYT/WindowControls.js b/extensions/CubesterYT/WindowControls.js new file mode 100644 index 0000000000..08d85d27e2 --- /dev/null +++ b/extensions/CubesterYT/WindowControls.js @@ -0,0 +1,542 @@ +// Name: Window Controls +// ID: cubesterWindowControls +// Description: Move, resize, rename the window, enter fullscreen, get screen size, and more. +// By: CubesterYT +// Original: BlueDome77 + +// Version V.1.0.0 + +(function (Scratch) { + "use strict"; + + const icon = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA0GVYSWZJSSoACAAAAAoAAAEEAAEAAABAAAAAAQEEAAEAAABAAAAAAgEDAAMAAACGAAAAEgEDAAEAAAABAAAAGgEFAAEAAACMAAAAGwEFAAEAAACUAAAAKAEDAAEAAAACAAAAMQECAA0AAACcAAAAMgECABQAAACqAAAAaYcEAAEAAAC+AAAAAAAAAAgACAAIAEgAAAABAAAASAAAAAEAAABHSU1QIDIuMTAuMzQAADIwMjM6MDg6MTUgMjI6MjU6MTcAAQABoAMAAQAAAAEAAAAAAAAA+Kkp0wAAAYRpQ0NQSUNDIHByb2ZpbGUAAHicfZE9SMNAHMVfW6VFKgp2KOKQoTpZKCriqFUoQoVQK7TqYHLpFzRpSFJcHAXXgoMfi1UHF2ddHVwFQfADxNXFSdFFSvxfUmgR48FxP97de9y9A/zNKlPNngSgapaRSSWFXH5VCL4ihEEEEEVCYqY+J4ppeI6ve/j4ehfnWd7n/hz9SsFkgE8gnmW6YRFvEE9vWjrnfeIIK0sK8TnxuEEXJH7kuuzyG+eSw36eGTGymXniCLFQ6mK5i1nZUImniGOKqlG+P+eywnmLs1qts/Y9+QvDBW1lmes0R5DCIpYgQoCMOiqowkKcVo0UExnaT3r4hx2/SC6ZXBUwciygBhWS4wf/g9/dmsXJCTcpnAR6X2z7YxQI7gKthm1/H9t26wQIPANXWsdfawIzn6Q3OlrsCBjYBi6uO5q8B1zuANEnXTIkRwrQ9BeLwPsZfVMeGLoF+tbc3tr7OH0AstRV+gY4OATGSpS97vHuUHdv/55p9/cDaOdyoyaJtEEAAA14aVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiCiAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgICB4bWxuczpHSU1QPSJodHRwOi8vd3d3LmdpbXAub3JnL3htcC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgeG1wTU06RG9jdW1lbnRJRD0iZ2ltcDpkb2NpZDpnaW1wOmVkNmVmMzUwLWVjZDAtNGIwZC1iZjVlLTUxOTVkZjI4YzRhYiIKICAgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpmN2Y5ODViOC0yMjg3LTQxNmQtOTFjMC0zNTY3ZmQ1ZjhmMjAiCiAgIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDplMzk3YTJiMC0zNDJjLTQ3MWMtYmQzNi0wNjExMTI2MDQwZmEiCiAgIGRjOkZvcm1hdD0iaW1hZ2UvcG5nIgogICBHSU1QOkFQST0iMi4wIgogICBHSU1QOlBsYXRmb3JtPSJMaW51eCIKICAgR0lNUDpUaW1lU3RhbXA9IjE2OTIxNTYzMTc3MjY0NjciCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4zNCIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjM6MDg6MTVUMjI6MjU6MTctMDU6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDIzOjA4OjE1VDIyOjI1OjE3LTA1OjAwIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MzlmYmE1ZjAtZmE4OC00M2ZjLTgyMjQtMGIwYjlhMGRkZDkyIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKExpbnV4KSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMy0wOC0xNVQyMjoyNToxNy0wNTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz45jkSCAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5wgQAxkRVAqqdwAAAchQTFRFAAAAGn+yG4CyHICzHX2yHYC1GoC0Gn+yGn+zGoCzG3+zG3+0G4CzHICzHIC0HIGzG3+zG4CzG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zG3+zHIC0HIC0G3+zHIC0G3+zG3+zHoK2IIS5IYW5I4e8JIi9JYm+Jou/JovAJ4zBKpDFLJLHLpXKMJjNMZnOMZnPMpnPM5vQM5vRNJzSNJ3TNZ7UNp/UN5/UOJ/UOZ/VO6DVPqHVQ6HVRqLWR6PWSKPWSaPWX6rYbK/abq/ab7DadrPbd7Pce7Tcgbfdgbjdgrjdhrrfh7rfibvfirzfi7zfjLzfjLzgjb3gjr3gjr7gj77gkL7gkb/glMDhnMTjncTjoMfkosfko8jkpMjlpcnlsc/ns9HotNHoutXqvtbrv9frwNfswNjsxNrtzN/vzd/vzeDvzeDwzuDwzuHw0eLw0uPx1uXy2Oby2Ofy2efz2+jz3ur04u324+725O727vT57/X68Pb68fb68vb78vf78/f68/f79vn8+fv9+/z+/P3+/f7+////xM3NqwAAADF0Uk5TAAEBAQEBAgMDAwMDAwMDAwgIEBEcJjlUXV5panOEhY2Qra6vt8bO2Nri4+zx9PT8/cI6cjwAAAABYktHRJfmbhuvAAAC10lEQVRYw7WX51/TUBSGr4KIiIoILaMT6LDL7pG2oSPHDW4FBwpO3CiCigP3QgUB77/rbdOkCaTkpsH3Qz7c5Dy/JPeec96DUA11GM12p8cfDPo9TrvZ2IG0aIvB6grFmQxb4AC4Apth4iGX1bCNMrzd5I4wLKwRy0TcpnaK8H0WXzILisomfRY1xM4ebyoPNZVPeXubNorvdEQHYUMNRh2dteO7AmlQVTrQXSN8ty2cAwrlwrZmpfi2vlgRqFSM9bcpxA8kOKAUlxhYR2juS4AGJfpb1wBsMQ6G7r94S6FX904AF7PJ47vDRZj4hSn1cxyK4S7Z/gdyML6CqbUyDrmA5Dy0OtIwtIDx6vyTx6qaml/FeGEI0o7qZvZEAe6S+DG6P3iFECYBor1i/njJ+Z3B+B3tHnzAeJqcaq+QWZYUlAEzABc+vxk+/vzLJbjz7emhmgD+WUhZKvnvy4uLnzB+dAvjr0e+Y3xODZD38a9gSlYXn+G/l8+u4JcHP+I/p9QAkDSV4re6s9XFw9fOA5y5eQxO3j4NqoCsu5EADBHZIo3EZyMGArAywuLsUUrNCgDGSgAuVgAsL1a0xF/lEm8uLS4LANZF6n9IfC0tqnxuqAMZ43oAcSMyMyJg7uIo0QjRqIJGqpoTAYwZ2TNQ/y5Axo6crB4A60Segh5AwYP8nB4A50dB0AOAA3oBQf2foPsn6t5G3QdJcpTrAJCjLEmmOgAkmSTpXAeApLOkoGgHlAqKpKRpB5RLmu6i2igt69oAfFmXNRZtAL6xyFqbJoDQ2qTNVRNAaK58e5/G+H297b1sMCbpDcbVtQYDNYsWZ4rS4vwoWZwmucma0GKyxuQmi7d516lt3u8bxOZ1rzeaww+pjObrBwpGE+3QanW3b7bZ1mb3+/YqDQwt9ANHi76Rp+v/DV0I7VId+3r2qM2t+gbPTRh9S2pQHr4bNE3w1fF//4bj/z8IjCsoNvb7bgAAAABJRU5ErkJggg=="; + + function getRandomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + class WindowControls { + getInfo() { + return { + id: "cubesterWindowControls", + name: Scratch.translate("Window Controls"), + color1: "#359ed4", + color2: "#298ec2", + color3: "#2081b3", + menuIconURI: icon, + docsURI: "https://extensions.turbowarp.org/CubesterYT/WindowControls", + + blocks: [ + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("May not work in normal browser tabs"), + }, + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Refer to Documentation for details"), + }, + { + opcode: "moveTo", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("move window to x: [X] y: [Y]"), + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "0", + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "0", + }, + }, + }, + { + opcode: "moveToPresets", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("move window to the [PRESETS]"), + arguments: { + PRESETS: { + type: Scratch.ArgumentType.STRING, + menu: "MOVE", + }, + }, + }, + { + opcode: "changeX", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("change window x by [X]"), + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "50", + }, + }, + }, + { + opcode: "setX", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set window x to [X]"), + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "100", + }, + }, + }, + { + opcode: "changeY", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("change window y by [Y]"), + arguments: { + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "50", + }, + }, + }, + { + opcode: "setY", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set window y to [Y]"), + arguments: { + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "100", + }, + }, + }, + { + opcode: "windowX", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("window x"), + }, + { + opcode: "windowY", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("window y"), + }, + + "---", + + { + opcode: "resizeTo", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("resize window to width: [W] height: [H]"), + arguments: { + W: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "480", + }, + H: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "360", + }, + }, + }, + { + opcode: "resizeToPresets", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("resize window to [PRESETS]"), + arguments: { + PRESETS: { + type: Scratch.ArgumentType.STRING, + menu: "RESIZE", + }, + }, + }, + { + opcode: "changeW", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("change window width by [W]"), + arguments: { + W: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "50", + }, + }, + }, + { + opcode: "setW", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set window width to [W]"), + arguments: { + W: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "1000", + }, + }, + }, + { + opcode: "changeH", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("change window height by [H]"), + arguments: { + H: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "50", + }, + }, + }, + { + opcode: "setH", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set window height to [H]"), + arguments: { + H: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: "1000", + }, + }, + }, + { + opcode: "matchStageSize", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("match stage size"), + }, + { + opcode: "windowW", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("window width"), + }, + { + opcode: "windowH", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("window height"), + }, + + "---", + + { + opcode: "isTouchingEdge", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is window touching screen edge?"), + }, + { + opcode: "screenW", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("screen width"), + }, + { + opcode: "screenH", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("screen height"), + }, + + "---", + + { + opcode: "isFocused", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is window focused?"), + }, + + "---", + + { + opcode: "changeTitleTo", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set window title to [TITLE]"), + arguments: { + TITLE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Hello World!"), + }, + }, + }, + { + opcode: "windowTitle", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("window title"), + }, + + "---", + + { + opcode: "enterFullscreen", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("enter fullscreen"), + }, + { + opcode: "exitFullscreen", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("exit fullscreen"), + }, + { + opcode: "isFullscreen", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is window fullscreen?"), + }, + + "---", + + { + opcode: "closeWindow", + blockType: Scratch.BlockType.COMMAND, + isTerminal: true, + text: Scratch.translate("close window"), + }, + ], + menus: { + MOVE: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("center"), + value: "center", + }, + { + text: Scratch.translate("right"), + value: "right", + }, + { + text: Scratch.translate("left"), + value: "left", + }, + { + text: Scratch.translate("top"), + value: "top", + }, + { + text: Scratch.translate("bottom"), + value: "bottom", + }, + { + text: Scratch.translate("top right"), + value: "top right", + }, + { + text: Scratch.translate("top left"), + value: "top left", + }, + { + text: Scratch.translate("bottom right"), + value: "bottom right", + }, + { + text: Scratch.translate("bottom left"), + value: "bottom left", + }, + { + text: Scratch.translate("random position"), + value: "random position", + }, + ], + }, + RESIZE: { + acceptReporters: true, + items: [ + "480x360", + "640x480", + "1280x720", + "1920x1080", + "2560x1440", + "2048x1080", + "3840x2160", + "7680x4320", + ], + }, + }, + }; + } + + moveTo(args) { + window.moveTo(args.X, args.Y); + Scratch.vm.runtime.requestRedraw(); + } + moveToPresets(args) { + if (args.PRESETS == "center") { + const left = (screen.width - window.outerWidth) / 2; + const top = (screen.height - window.outerHeight) / 2; + window.moveTo(left, top); + } else if (args.PRESETS == "right") { + const right = screen.width - window.outerWidth; + const top = (screen.height - window.outerHeight) / 2; + window.moveTo(right, top); + } else if (args.PRESETS == "left") { + const top = (screen.height - window.outerHeight) / 2; + window.moveTo(0, top); + } else if (args.PRESETS == "top") { + const left = (screen.width - window.outerWidth) / 2; + window.moveTo(left, 0); + } else if (args.PRESETS == "bottom") { + const left = (screen.width - window.outerWidth) / 2; + const bottom = screen.height - window.outerHeight; + window.moveTo(left, bottom); + } else if (args.PRESETS == "top right") { + const right = screen.width - window.outerWidth; + window.moveTo(right, 0); + } else if (args.PRESETS == "top left") { + window.moveTo(0, 0); + } else if (args.PRESETS == "bottom right") { + const right = screen.width - window.outerWidth; + const bottom = screen.height - window.outerHeight; + window.moveTo(right, bottom); + } else if (args.PRESETS == "bottom left") { + const bottom = screen.height - window.outerHeight; + window.moveTo(0, bottom); + } else if (args.PRESETS == "random position") { + const randomX = getRandomInt(0, screen.width); + const randomY = getRandomInt(0, screen.height); + window.moveTo(randomX, randomY); + } + Scratch.vm.runtime.requestRedraw(); + } + changeX(args) { + window.moveBy(args.X, 0); + Scratch.vm.runtime.requestRedraw(); + } + setX(args) { + const currentY = window.screenY; + window.moveTo(args.X, currentY); + Scratch.vm.runtime.requestRedraw(); + } + changeY(args) { + window.moveBy(0, args.Y); + Scratch.vm.runtime.requestRedraw(); + } + setY(args) { + const currentX = window.screenX; + window.moveTo(currentX, args.Y); + Scratch.vm.runtime.requestRedraw(); + } + windowX() { + return window.screenLeft; + } + windowY() { + return window.screenTop; + } + resizeTo(args) { + window.resizeTo(args.W, args.H); + Scratch.vm.runtime.requestRedraw(); + } + resizeToPresets(args) { + if (args.PRESETS == "480x360") { + window.resizeTo( + 480 + (window.outerWidth - window.innerWidth), + 360 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "640x480") { + window.resizeTo( + 640 + (window.outerWidth - window.innerWidth), + 480 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "1280x720") { + window.resizeTo( + 1280 + (window.outerWidth - window.innerWidth), + 720 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "1920x1080") { + window.resizeTo( + 1920 + (window.outerWidth - window.innerWidth), + 1080 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "2560x1440") { + window.resizeTo( + 2560 + (window.outerWidth - window.innerWidth), + 1440 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "2048x1080") { + window.resizeTo( + 2048 + (window.outerWidth - window.innerWidth), + 1080 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "3840x2160") { + window.resizeTo( + 3840 + (window.outerWidth - window.innerWidth), + 2160 + (window.outerHeight - window.innerHeight) + ); + } else if (args.PRESETS == "7680x4320") { + window.resizeTo( + 7680 + (window.outerWidth - window.innerWidth), + 4320 + (window.outerHeight - window.innerHeight) + ); + } + Scratch.vm.runtime.requestRedraw(); + } + changeW(args) { + window.resizeBy(args.W, 0); + Scratch.vm.runtime.requestRedraw(); + } + setW(args) { + const currentH = window.outerHeight; + window.resizeTo(args.W, currentH); + Scratch.vm.runtime.requestRedraw(); + } + changeH(args) { + window.resizeBy(0, args.H); + Scratch.vm.runtime.requestRedraw(); + } + setH(args) { + const currentW = window.outerWidth; + window.resizeTo(currentW, args.H); + Scratch.vm.runtime.requestRedraw(); + } + matchStageSize() { + window.resizeTo( + Scratch.vm.runtime.stageWidth + (window.outerWidth - window.innerWidth), + Scratch.vm.runtime.stageHeight + + (window.outerHeight - window.innerHeight) + ); + Scratch.vm.runtime.requestRedraw(); + } + windowW() { + return window.outerWidth; + } + windowH() { + return window.outerHeight; + } + isTouchingEdge() { + const edgeX = screen.width - window.outerWidth; + const edgeY = screen.height - window.outerHeight; + return ( + window.screenLeft <= 0 || + window.screenTop <= 0 || + window.screenLeft >= edgeX || + window.screenTop >= edgeY + ); + } + screenW() { + return screen.width; + } + screenH() { + return screen.height; + } + isFocused() { + return document.hasFocus(); + } + changeTitleTo(args) { + document.title = args.TITLE; + } + windowTitle() { + return document.title; + } + enterFullscreen() { + if (document.fullscreenElement == null) { + document.documentElement.requestFullscreen(); + } + } + exitFullscreen() { + if (document.fullscreenElement !== null) { + document.exitFullscreen(); + } + } + isFullscreen() { + return document.fullscreenElement !== null; + } + closeWindow() { + const editorConfirmation = Scratch.translate({ + id: "editorConfirmation", + default: + "Are you sure you want to close this window?\n\n(This message will not appear when the project is packaged)", + }); + // @ts-expect-error + if (typeof ScratchBlocks === "undefined" || confirm(editorConfirmation)) { + window.close(); + } + } + } + Scratch.extensions.register(new WindowControls()); +})(Scratch); diff --git a/extensions/DNin/wake-lock.js b/extensions/DNin/wake-lock.js index 41b98c8aa2..d3a9d4f56a 100644 --- a/extensions/DNin/wake-lock.js +++ b/extensions/DNin/wake-lock.js @@ -24,13 +24,16 @@ getInfo() { return { id: "dninwakelock", - name: "Wake Lock", + name: Scratch.translate("Wake Lock"), docsURI: "https://extensions.turbowarp.org/DNin/wake-lock", blocks: [ { opcode: "setWakeLock", blockType: Scratch.BlockType.COMMAND, - text: "turn wake lock [enabled]", + text: Scratch.translate({ + default: "turn wake lock [enabled]", + description: "[enabled] is a drop down with items 'on' and 'off'", + }), arguments: { enabled: { type: Scratch.ArgumentType.STRING, @@ -42,7 +45,7 @@ { opcode: "isLocked", blockType: Scratch.BlockType.BOOLEAN, - text: "is wake lock active?", + text: Scratch.translate("is wake lock active?"), }, ], menus: { @@ -50,11 +53,11 @@ acceptReporters: true, items: [ { - text: "on", + text: Scratch.translate("on"), value: "true", }, { - text: "off", + text: Scratch.translate("off"), value: "false", }, ], diff --git a/extensions/DT/cameracontrols.js b/extensions/DT/cameracontrols.js index 4dc52493d6..c3eca35460 100644 --- a/extensions/DT/cameracontrols.js +++ b/extensions/DT/cameracontrols.js @@ -1,4 +1,4 @@ -// Name: Camera Controls +// Name: Camera Controls (Very Buggy) // ID: DTcameracontrols // Description: Move the visible part of the stage. // By: DT @@ -36,10 +36,10 @@ rot = -cameraDirection + 90 ) { rot = (rot / 180) * Math.PI; - let s = Math.sin(rot) * scale; - let c = Math.cos(rot) * scale; - let w = vm.runtime.stageWidth / 2; - let h = vm.runtime.stageHeight / 2; + const s = Math.sin(rot) * scale; + const c = Math.cos(rot) * scale; + const w = vm.runtime.stageWidth / 2; + const h = vm.runtime.stageHeight / 2; vm.renderer._projection = [ c / w, -s / h, @@ -61,25 +61,180 @@ vm.renderer.dirty = true; } + function updateCameraBG(color = cameraBG) { + const rgb = Scratch.Cast.toRgbColorList(color); + Scratch.vm.renderer.setBackgroundColor( + rgb[0] / 255, + rgb[1] / 255, + rgb[2] / 255 + ); + } + // tell resize to update camera as well vm.runtime.on("STAGE_SIZE_CHANGED", (_) => updateCamera()); - // fix mouse positions - let oldSX = vm.runtime.ioDevices.mouse.getScratchX; - let oldSY = vm.runtime.ioDevices.mouse.getScratchY; + vm.runtime.on("RUNTIME_DISPOSED", (_) => { + cameraX = 0; + cameraY = 0; + cameraZoom = 100; + cameraDirection = 90; + cameraBG = "#ffffff"; + updateCamera(); + updateCameraBG(); + }); + function _translateX(x, fromTopLeft = false, multiplier = 1, doZoom = true) { + const w = fromTopLeft ? vm.runtime.stageWidth / 2 : 0; + return (x - w) / (doZoom ? cameraZoom / 100 : 1) + w + cameraX * multiplier; + } + + function _translateY(y, fromTopLeft = false, multiplier = 1, doZoom = true) { + const h = fromTopLeft ? vm.runtime.stageHeight / 2 : 0; + return (y - h) / (doZoom ? cameraZoom / 100 : 1) + h + cameraY * multiplier; + } + + function rotate(cx, cy, x, y, radians) { + const cos = Math.cos(radians), + sin = Math.sin(radians), + nx = cos * (x - cx) + sin * (y - cy) + cx, + ny = cos * (y - cy) - sin * (x - cx) + cy; + return [nx, ny]; + } + + // rotation hell + function translateX( + x, + fromTopLeft = false, + xMult = 1, + doZoom = true, + y = 0, + yMult = xMult + ) { + if ((cameraDirection - 90) % 360 === 0 || !doZoom) { + return _translateX(x, fromTopLeft, xMult, doZoom); + } else { + const w = fromTopLeft ? vm.runtime.stageWidth / 2 : 0; + const h = fromTopLeft ? vm.runtime.stageHeight / 2 : 0; + const rotated = rotate( + cameraX + w, + cameraY + h, + _translateX(x, fromTopLeft, xMult, doZoom), + _translateY(y, fromTopLeft, yMult, doZoom), + ((-cameraDirection + 90) / 180) * Math.PI + ); + return rotated[0]; + } + } + function translateY( + y, + fromTopLeft = false, + yMult = 1, + doZoom = true, + x = 0, + xMult = yMult + ) { + if ((cameraDirection - 90) % 360 === 0 || !doZoom) { + return _translateY(y, fromTopLeft, yMult, doZoom); + } else { + const w = fromTopLeft ? vm.runtime.stageWidth / 2 : 0; + const h = fromTopLeft ? vm.runtime.stageHeight / 2 : 0; + const rotated = rotate( + cameraX + w, + cameraY + h, + _translateX(x, fromTopLeft, xMult, doZoom), + _translateY(y, fromTopLeft, yMult, doZoom), + ((-cameraDirection + 90) / 180) * Math.PI + ); + return rotated[1]; + } + } + + // fix mouse positions + const oldSX = vm.runtime.ioDevices.mouse.getScratchX; + const oldSY = vm.runtime.ioDevices.mouse.getScratchY; vm.runtime.ioDevices.mouse.getScratchX = function (...a) { - return ((oldSX.apply(this, a) + cameraX) / cameraZoom) * 100; + return translateX( + oldSX.apply(this, a), + false, + 1, + true, + oldSY.apply(this, a), + 1 + ); }; vm.runtime.ioDevices.mouse.getScratchY = function (...a) { - return ((oldSY.apply(this, a) + cameraY) / cameraZoom) * 100; + return translateY( + oldSY.apply(this, a), + false, + 1, + true, + oldSX.apply(this, a), + 1 + ); }; + const oldCX = vm.runtime.ioDevices.mouse.getClientX; + const oldCY = vm.runtime.ioDevices.mouse.getClientY; + vm.runtime.ioDevices.mouse.getClientX = function (...a) { + return translateX( + oldCX.apply(this, a), + true, + 1, + true, + oldCY.apply(this, a), + -1 + ); + }; + vm.runtime.ioDevices.mouse.getClientY = function (...a) { + return translateY( + oldCY.apply(this, a), + true, + -1, + true, + oldCX.apply(this, a), + 1 + ); + }; + + const oldPick = vm.renderer.pick; + vm.renderer.pick = function (x, y) { + return oldPick.call( + this, + translateX(x, true, 1, true, y, -1), + translateY(y, true, -1, true, x, 1) + ); + }; + + const oldExtract = vm.renderer.extractDrawableScreenSpace; + vm.renderer.extractDrawableScreenSpace = function (...args) { + const extracted = oldExtract.apply(this, args); + extracted.x = translateX(extracted.x, false, -1, false, extracted.y, 1); + extracted.y = translateY(extracted.y, false, 1, false, extracted.x, -1); + return extracted; + }; + + // @ts-expect-error + if (vm.runtime.ext_scratch3_looks) { + // @ts-expect-error + const oldPosBubble = vm.runtime.ext_scratch3_looks._positionBubble; + // @ts-expect-error + vm.runtime.ext_scratch3_looks._positionBubble = function (target) { + // it's harder to limit speech bubbles to the camera region... + // it's easier to just remove speech bubble bounds entirely + const oldGetNativeSize = this.runtime.renderer.getNativeSize; + this.runtime.renderer.getNativeSize = () => [Infinity, Infinity]; + try { + return oldPosBubble.call(this, target); + } finally { + this.runtime.renderer.getNativeSize = oldGetNativeSize; + } + }; + } class Camera { getInfo() { return { id: "DTcameracontrols", - name: "Camera", + name: Scratch.translate("Camera (Very Buggy)"), color1: "#ff4da7", color2: "#de4391", @@ -91,7 +246,7 @@ { opcode: "moveSteps", blockType: Scratch.BlockType.COMMAND, - text: "move camera [val] steps", + text: Scratch.translate("move camera [val] steps"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -102,7 +257,7 @@ { opcode: "rotateCW", blockType: Scratch.BlockType.COMMAND, - text: "turn camera [image] [val] degrees", + text: Scratch.translate("turn camera [image] [val] degrees"), arguments: { image: { type: Scratch.ArgumentType.IMAGE, @@ -117,7 +272,7 @@ { opcode: "rotateCCW", blockType: Scratch.BlockType.COMMAND, - text: "turn camera [image] [val] degrees", + text: Scratch.translate("turn camera [image] [val] degrees"), arguments: { image: { type: Scratch.ArgumentType.IMAGE, @@ -133,7 +288,7 @@ { opcode: "goTo", blockType: Scratch.BlockType.COMMAND, - text: "move camera to [sprite]", + text: Scratch.translate("move camera to [sprite]"), arguments: { sprite: { type: Scratch.ArgumentType.STRING, @@ -144,7 +299,7 @@ { opcode: "setBoth", blockType: Scratch.BlockType.COMMAND, - text: "set camera to x: [x] y: [y]", + text: Scratch.translate("set camera to x: [x] y: [y]"), arguments: { x: { type: Scratch.ArgumentType.NUMBER, @@ -160,7 +315,7 @@ { opcode: "setDirection", blockType: Scratch.BlockType.COMMAND, - text: "set camera direction to [val]", + text: Scratch.translate("set camera direction to [val]"), arguments: { val: { type: Scratch.ArgumentType.ANGLE, @@ -171,7 +326,7 @@ { opcode: "pointTowards", blockType: Scratch.BlockType.COMMAND, - text: "point camera towards [sprite]", + text: Scratch.translate("point camera towards [sprite]"), arguments: { sprite: { type: Scratch.ArgumentType.STRING, @@ -183,7 +338,7 @@ { opcode: "changeX", blockType: Scratch.BlockType.COMMAND, - text: "change camera x by [val]", + text: Scratch.translate("change camera x by [val]"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -194,7 +349,7 @@ { opcode: "setX", blockType: Scratch.BlockType.COMMAND, - text: "set camera x to [val]", + text: Scratch.translate("set camera x to [val]"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -205,7 +360,7 @@ { opcode: "changeY", blockType: Scratch.BlockType.COMMAND, - text: "change camera y by [val]", + text: Scratch.translate("change camera y by [val]"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -216,7 +371,7 @@ { opcode: "setY", blockType: Scratch.BlockType.COMMAND, - text: "set camera y to [val]", + text: Scratch.translate("set camera y to [val]"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -228,23 +383,36 @@ { opcode: "getX", blockType: Scratch.BlockType.REPORTER, - text: "camera x", + text: Scratch.translate("camera x"), }, { opcode: "getY", blockType: Scratch.BlockType.REPORTER, - text: "camera y", + text: Scratch.translate("camera y"), }, { opcode: "getDirection", blockType: Scratch.BlockType.REPORTER, - text: "camera direction", + text: Scratch.translate("camera direction"), }, + /* + // debugging blocks + { + opcode: "getCX", + blockType: Scratch.BlockType.REPORTER, + text: "client x", + }, + { + opcode: "getCY", + blockType: Scratch.BlockType.REPORTER, + text: "client y", + }, + */ "---", { opcode: "changeZoom", blockType: Scratch.BlockType.COMMAND, - text: "change camera zoom by [val]", + text: Scratch.translate("change camera zoom by [val]"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -255,7 +423,7 @@ { opcode: "setZoom", blockType: Scratch.BlockType.COMMAND, - text: "set camera zoom to [val] %", + text: Scratch.translate("set camera zoom to [val] %"), arguments: { val: { type: Scratch.ArgumentType.NUMBER, @@ -266,13 +434,13 @@ { opcode: "getZoom", blockType: Scratch.BlockType.REPORTER, - text: "camera zoom", + text: Scratch.translate("camera zoom"), }, "---", { opcode: "setCol", blockType: Scratch.BlockType.COMMAND, - text: "set background color to [val]", + text: Scratch.translate("set background color to [val]"), arguments: { val: { type: Scratch.ArgumentType.COLOR, @@ -282,7 +450,7 @@ { opcode: "getCol", blockType: Scratch.BlockType.REPORTER, - text: "background color", + text: Scratch.translate("background color"), }, ], menus: { @@ -294,13 +462,20 @@ }; } + getCX() { + return vm.runtime.ioDevices.mouse.getClientX(); + } + getCY() { + return vm.runtime.ioDevices.mouse.getClientY(); + } + getSprites() { - let sprites = []; + const sprites = []; Scratch.vm.runtime.targets.forEach((e) => { if (e.isOriginal && !e.isStage) sprites.push(e.sprite.name); }); if (sprites.length === 0) { - sprites.push("no sprites exist"); + sprites.push(Scratch.translate("no sprites exist")); } return sprites; } @@ -369,19 +544,14 @@ return cameraDirection; } setCol(args, util) { - const rgb = Scratch.Cast.toRgbColorList(args.val); - Scratch.vm.renderer.setBackgroundColor( - rgb[0] / 255, - rgb[1] / 255, - rgb[2] / 255 - ); cameraBG = args.val; + updateCameraBG(); } getCol() { return cameraBG; } moveSteps(args) { - let dir = ((-cameraDirection + 90) * Math.PI) / 180; + const dir = ((-cameraDirection + 90) * Math.PI) / 180; cameraX += args.val * Math.cos(dir); cameraY += args.val * Math.sin(dir); updateCamera(); @@ -400,8 +570,8 @@ const target = Scratch.Cast.toString(args.sprite); const sprite = vm.runtime.getSpriteTargetByName(target); if (!sprite) return; - let targetX = sprite.x; - let targetY = sprite.y; + const targetX = sprite.x; + const targetY = sprite.y; const dx = targetX - cameraX; const dy = targetY - cameraY; cameraDirection = 90 - this.radToDeg(Math.atan2(dy, dx)); diff --git a/extensions/JeremyGamer13/tween.js b/extensions/JeremyGamer13/tween.js index e19e1b5d94..850dc6bb12 100644 --- a/extensions/JeremyGamer13/tween.js +++ b/extensions/JeremyGamer13/tween.js @@ -1,313 +1,519 @@ -// Name: Tween -// ID: jeremygamerTweening -// Description: Easing methods for smooth animations. -// By: JeremyGamer13 - -(function (Scratch) { - "use strict"; - - const EasingMethods = [ - "linear", - "sine", - "quad", - "cubic", - "quart", - "quint", - "expo", - "circ", - "back", - "elastic", - "bounce", - ]; - - const BlockType = Scratch.BlockType; - const ArgumentType = Scratch.ArgumentType; - const Cast = Scratch.Cast; - - class Tween { - getInfo() { - return { - id: "jeremygamerTweening", - name: "Tweening", - blocks: [ - { - opcode: "tweenValue", - text: "[MODE] ease [DIRECTION] [START] to [END] by [AMOUNT]%", - disableMonitor: true, - blockType: BlockType.REPORTER, - arguments: { - MODE: { type: ArgumentType.STRING, menu: "modes" }, - DIRECTION: { type: ArgumentType.STRING, menu: "direction" }, - START: { type: ArgumentType.NUMBER, defaultValue: 0 }, - END: { type: ArgumentType.NUMBER, defaultValue: 100 }, - AMOUNT: { type: ArgumentType.NUMBER, defaultValue: 50 }, - }, - }, - ], - menus: { - modes: { - acceptReporters: true, - items: EasingMethods.map((item) => ({ text: item, value: item })), - }, - direction: { - acceptReporters: true, - items: ["in", "out", "in out"].map((item) => ({ - text: item, - value: item, - })), - }, - }, - }; - } - - // utilities - multiplierToNormalNumber(mul, start, end) { - const multiplier = end - start; - const result = mul * multiplier + start; - return result; - } - - // blocks - tweenValue(args) { - const easeMethod = Cast.toString(args.MODE); - const easeDirection = Cast.toString(args.DIRECTION); - - const start = Cast.toNumber(args.START); - const end = Cast.toNumber(args.END); - - // easing method does not exist, return starting number - if (!EasingMethods.includes(easeMethod)) return start; - // easing method is not implemented, return starting number - if (!this[easeMethod]) return start; - - const progress = Cast.toNumber(args.AMOUNT) / 100; - - const tweened = this[easeMethod](progress, easeDirection); - return this.multiplierToNormalNumber(tweened, start, end); - } - - // easing functions (placed below blocks for organization) - linear(x) { - // lol - return x; - } - - sine(x, dir) { - switch (dir) { - case "in": { - return 1 - Math.cos((x * Math.PI) / 2); - } - case "out": { - return Math.sin((x * Math.PI) / 2); - } - case "in out": { - return -(Math.cos(Math.PI * x) - 1) / 2; - } - default: - return 0; - } - } - - quad(x, dir) { - switch (dir) { - case "in": { - return x * x; - } - case "out": { - return 1 - (1 - x) * (1 - x); - } - case "in out": { - return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; - } - default: - return 0; - } - } - - cubic(x, dir) { - switch (dir) { - case "in": { - return x * x * x; - } - case "out": { - return 1 - Math.pow(1 - x, 3); - } - case "in out": { - return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; - } - default: - return 0; - } - } - - quart(x, dir) { - switch (dir) { - case "in": { - return x * x * x * x; - } - case "out": { - return 1 - Math.pow(1 - x, 4); - } - case "in out": { - return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2; - } - default: - return 0; - } - } - - quint(x, dir) { - switch (dir) { - case "in": { - return x * x * x * x * x; - } - case "out": { - return 1 - Math.pow(1 - x, 5); - } - case "in out": { - return x < 0.5 - ? 16 * x * x * x * x * x - : 1 - Math.pow(-2 * x + 2, 5) / 2; - } - default: - return 0; - } - } - - expo(x, dir) { - switch (dir) { - case "in": { - return x === 0 ? 0 : Math.pow(2, 10 * x - 10); - } - case "out": { - return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); - } - case "in out": { - return x === 0 - ? 0 - : x === 1 - ? 1 - : x < 0.5 - ? Math.pow(2, 20 * x - 10) / 2 - : (2 - Math.pow(2, -20 * x + 10)) / 2; - } - default: - return 0; - } - } - - circ(x, dir) { - switch (dir) { - case "in": { - return 1 - Math.sqrt(1 - Math.pow(x, 2)); - } - case "out": { - return Math.sqrt(1 - Math.pow(x - 1, 2)); - } - case "in out": { - return x < 0.5 - ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 - : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2; - } - default: - return 0; - } - } - - back(x, dir) { - switch (dir) { - case "in": { - const c1 = 1.70158; - const c3 = c1 + 1; - - return c3 * x * x * x - c1 * x * x; - } - case "out": { - const c1 = 1.70158; - const c3 = c1 + 1; - - return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); - } - case "in out": { - const c1 = 1.70158; - const c2 = c1 * 1.525; - - return x < 0.5 - ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 - : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; - } - default: - return 0; - } - } - - elastic(x, dir) { - switch (dir) { - case "in": { - const c4 = (2 * Math.PI) / 3; - - return x === 0 - ? 0 - : x === 1 - ? 1 - : -Math.pow(2, 10 * x - 10) * Math.sin((x * 10 - 10.75) * c4); - } - case "out": { - const c4 = (2 * Math.PI) / 3; - - return x === 0 - ? 0 - : x === 1 - ? 1 - : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; - } - case "in out": { - const c5 = (2 * Math.PI) / 4.5; - - return x === 0 - ? 0 - : x === 1 - ? 1 - : x < 0.5 - ? -(Math.pow(2, 20 * x - 10) * Math.sin((20 * x - 11.125) * c5)) / 2 - : (Math.pow(2, -20 * x + 10) * Math.sin((20 * x - 11.125) * c5)) / - 2 + - 1; - } - default: - return 0; - } - } - - bounce(x, dir) { - switch (dir) { - case "in": { - return 1 - this.bounce(1 - x, "out"); - } - case "out": { - const n1 = 7.5625; - const d1 = 2.75; - - if (x < 1 / d1) { - return n1 * x * x; - } else if (x < 2 / d1) { - return n1 * (x -= 1.5 / d1) * x + 0.75; - } else if (x < 2.5 / d1) { - return n1 * (x -= 2.25 / d1) * x + 0.9375; - } else { - return n1 * (x -= 2.625 / d1) * x + 0.984375; - } - } - case "in out": { - return x < 0.5 - ? (1 - this.bounce(1 - 2 * x, "out")) / 2 - : (1 + this.bounce(2 * x - 1, "out")) / 2; - } - default: - return 0; - } - } - } - - Scratch.extensions.register(new Tween()); -})(Scratch); +// Name: Tween +// ID: jeremygamerTweening +// Description: Easing methods for smooth animations. +// By: JeremyGamer13 + +(function (Scratch) { + "use strict"; + + const BlockType = Scratch.BlockType; + const ArgumentType = Scratch.ArgumentType; + const Cast = Scratch.Cast; + + /** + * @param {number} time should be 0-1 + * @param {number} a value at 0 + * @param {number} b value at 1 + * @returns {number} + */ + const interpolate = (time, a, b) => { + // don't restrict range of time as some easing functions are expected to go outside the range + const multiplier = b - a; + const result = time * multiplier + a; + return result; + }; + + const linear = (x) => x; + + const sine = (x, dir) => { + switch (dir) { + case "in": { + return 1 - Math.cos((x * Math.PI) / 2); + } + case "out": { + return Math.sin((x * Math.PI) / 2); + } + case "in out": { + return -(Math.cos(Math.PI * x) - 1) / 2; + } + default: + return 0; + } + }; + + const quad = (x, dir) => { + switch (dir) { + case "in": { + return x * x; + } + case "out": { + return 1 - (1 - x) * (1 - x); + } + case "in out": { + return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; + } + default: + return 0; + } + }; + + const cubic = (x, dir) => { + switch (dir) { + case "in": { + return x * x * x; + } + case "out": { + return 1 - Math.pow(1 - x, 3); + } + case "in out": { + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; + } + default: + return 0; + } + }; + + const quart = (x, dir) => { + switch (dir) { + case "in": { + return x * x * x * x; + } + case "out": { + return 1 - Math.pow(1 - x, 4); + } + case "in out": { + return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2; + } + default: + return 0; + } + }; + + const quint = (x, dir) => { + switch (dir) { + case "in": { + return x * x * x * x * x; + } + case "out": { + return 1 - Math.pow(1 - x, 5); + } + case "in out": { + return x < 0.5 + ? 16 * x * x * x * x * x + : 1 - Math.pow(-2 * x + 2, 5) / 2; + } + default: + return 0; + } + }; + + const expo = (x, dir) => { + switch (dir) { + case "in": { + return x === 0 ? 0 : Math.pow(2, 10 * x - 10); + } + case "out": { + return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); + } + case "in out": { + return x === 0 + ? 0 + : x === 1 + ? 1 + : x < 0.5 + ? Math.pow(2, 20 * x - 10) / 2 + : (2 - Math.pow(2, -20 * x + 10)) / 2; + } + default: + return 0; + } + }; + + const circ = (x, dir) => { + switch (dir) { + case "in": { + return 1 - Math.sqrt(1 - Math.pow(x, 2)); + } + case "out": { + return Math.sqrt(1 - Math.pow(x - 1, 2)); + } + case "in out": { + return x < 0.5 + ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 + : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2; + } + default: + return 0; + } + }; + + const back = (x, dir) => { + switch (dir) { + case "in": { + const c1 = 1.70158; + const c3 = c1 + 1; + return c3 * x * x * x - c1 * x * x; + } + case "out": { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); + } + case "in out": { + const c1 = 1.70158; + const c2 = c1 * 1.525; + return x < 0.5 + ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 + : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; + } + default: + return 0; + } + }; + + const elastic = (x, dir) => { + switch (dir) { + case "in": { + const c4 = (2 * Math.PI) / 3; + return x === 0 + ? 0 + : x === 1 + ? 1 + : -Math.pow(2, 10 * x - 10) * Math.sin((x * 10 - 10.75) * c4); + } + case "out": { + const c4 = (2 * Math.PI) / 3; + return x === 0 + ? 0 + : x === 1 + ? 1 + : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; + } + case "in out": { + const c5 = (2 * Math.PI) / 4.5; + return x === 0 + ? 0 + : x === 1 + ? 1 + : x < 0.5 + ? -(Math.pow(2, 20 * x - 10) * Math.sin((20 * x - 11.125) * c5)) / + 2 + : (Math.pow(2, -20 * x + 10) * Math.sin((20 * x - 11.125) * c5)) / + 2 + + 1; + } + default: + return 0; + } + }; + + const bounce = (x, dir) => { + switch (dir) { + case "in": { + return 1 - bounce(1 - x, "out"); + } + case "out": { + const n1 = 7.5625; + const d1 = 2.75; + if (x < 1 / d1) { + return n1 * x * x; + } else if (x < 2 / d1) { + return n1 * (x -= 1.5 / d1) * x + 0.75; + } else if (x < 2.5 / d1) { + return n1 * (x -= 2.25 / d1) * x + 0.9375; + } else { + return n1 * (x -= 2.625 / d1) * x + 0.984375; + } + } + case "in out": { + return x < 0.5 + ? (1 - bounce(1 - 2 * x, "out")) / 2 + : (1 + bounce(2 * x - 1, "out")) / 2; + } + default: + return 0; + } + }; + + const EasingMethods = { + linear, + sine, + quad, + cubic, + quart, + quint, + expo, + circ, + back, + elastic, + bounce, + }; + + const now = () => Scratch.vm.runtime.currentMSecs; + + class Tween { + getInfo() { + return { + id: "jeremygamerTweening", + name: "Tweening", + blocks: [ + { + opcode: "tweenValue", + text: "[MODE] ease [DIRECTION] [START] to [END] by [AMOUNT]%", + disableMonitor: true, + blockType: BlockType.REPORTER, + arguments: { + MODE: { + type: ArgumentType.STRING, + menu: "modes", + }, + DIRECTION: { + type: ArgumentType.STRING, + menu: "direction", + }, + START: { + type: ArgumentType.NUMBER, + defaultValue: 0, + }, + END: { + type: ArgumentType.NUMBER, + defaultValue: 100, + }, + AMOUNT: { + type: ArgumentType.NUMBER, + defaultValue: 50, + }, + }, + }, + { + opcode: "tweenVariable", + text: "tween variable [VAR] to [VALUE] over [SEC] seconds using [MODE] ease [DIRECTION]", + blockType: BlockType.COMMAND, + arguments: { + VAR: { + type: ArgumentType.STRING, + menu: "vars", + }, + VALUE: { + type: ArgumentType.NUMBER, + defaultValue: 100, + }, + SEC: { + type: ArgumentType.NUMBER, + defaultValue: 1, + }, + MODE: { + type: ArgumentType.STRING, + menu: "modes", + }, + DIRECTION: { + type: ArgumentType.STRING, + menu: "direction", + }, + }, + }, + { + opcode: "tweenXY", + text: "tween to x: [X] y: [Y] over [SEC] seconds using [MODE] ease [DIRECTION]", + blockType: BlockType.COMMAND, + arguments: { + PROPERTY: { + type: ArgumentType.STRING, + menu: "properties", + }, + X: { + type: ArgumentType.NUMBER, + defaultValue: 100, + }, + Y: { + type: ArgumentType.NUMBER, + defaultValue: 100, + }, + SEC: { + type: ArgumentType.NUMBER, + defaultValue: 1, + }, + MODE: { + type: ArgumentType.STRING, + menu: "modes", + }, + DIRECTION: { + type: ArgumentType.STRING, + menu: "direction", + }, + }, + }, + { + opcode: "tweenProperty", + text: "tween [PROPERTY] to [VALUE] over [SEC] seconds using [MODE] ease [DIRECTION]", + blockType: BlockType.COMMAND, + arguments: { + PROPERTY: { + type: ArgumentType.STRING, + menu: "properties", + }, + VALUE: { + type: ArgumentType.NUMBER, + defaultValue: 100, + }, + SEC: { + type: ArgumentType.NUMBER, + defaultValue: 1, + }, + MODE: { + type: ArgumentType.STRING, + menu: "modes", + }, + DIRECTION: { + type: ArgumentType.STRING, + menu: "direction", + }, + }, + }, + ], + menus: { + modes: { + acceptReporters: true, + items: Object.keys(EasingMethods), + }, + direction: { + acceptReporters: true, + items: ["in", "out", "in out"], + }, + vars: { + acceptReporters: false, // for Scratch parity + items: "getVariables", + }, + properties: { + acceptReporters: true, + items: ["x position", "y position", "direction", "size"], + }, + }, + }; + } + + getVariables() { + const variables = + // @ts-expect-error + typeof Blockly === "undefined" + ? [] + : // @ts-expect-error + Blockly.getMainWorkspace() + .getVariableMap() + .getVariablesOfType("") + .map((model) => ({ + text: model.name, + value: model.getId(), + })); + if (variables.length > 0) { + return variables; + } else { + return [{ text: "", value: "" }]; + } + } + + tweenValue(args) { + const easeMethod = Cast.toString(args.MODE); + const easeDirection = Cast.toString(args.DIRECTION); + const start = Cast.toNumber(args.START); + const end = Cast.toNumber(args.END); + const progress = Cast.toNumber(args.AMOUNT) / 100; + + if (!Object.prototype.hasOwnProperty.call(EasingMethods, easeMethod)) { + // Unknown method + return start; + } + const easingFunction = EasingMethods[easeMethod]; + + const tweened = easingFunction(progress, easeDirection); + return interpolate(tweened, start, end); + } + + _tweenValue(args, util, id, valueArgName, currentValue) { + // Only use args on first run. For later executions grab everything from stackframe. + // This ensures that if the arguments change, the tweening won't change. This matches + // the vanilla Scratch glide blocks. + const state = util.stackFrame[id]; + + if (!state) { + // First run, need to start timer + util.yield(); + + const durationMS = Cast.toNumber(args.SEC) * 1000; + const easeMethod = Cast.toString(args.MODE); + const easeDirection = Cast.toString(args.DIRECTION); + const start = currentValue; + const end = Cast.toNumber(args[valueArgName]); + + let easingFunction; + if (Object.prototype.hasOwnProperty.call(EasingMethods, easeMethod)) { + easingFunction = EasingMethods[easeMethod]; + } else { + easingFunction = EasingMethods.linear; + } + + util.stackFrame[id] = { + startTimeMS: now(), + durationMS, + easingFunction, + easeDirection, + start, + end, + }; + + return start; + } else if (now() - state.startTimeMS >= state.durationMS) { + // Done + return util.stackFrame[id].end; + } else { + // Still running + util.yield(); + + const progress = (now() - state.startTimeMS) / state.durationMS; + const tweened = state.easingFunction(progress, state.easeDirection); + return interpolate(tweened, state.start, state.end); + } + } + + tweenVariable(args, util) { + const variable = util.target.lookupVariableById(args.VAR); + const value = this._tweenValue(args, util, "", "VALUE", variable.value); + if (variable && variable.type === "") { + variable.value = value; + } + } + + tweenXY(args, util) { + const x = this._tweenValue(args, util, "x", "X", util.target.x); + const y = this._tweenValue(args, util, "y", "Y", util.target.y); + util.target.setXY(x, y); + } + + tweenProperty(args, util) { + let currentValue = 0; + if (args.PROPERTY === "x position") { + currentValue = util.target.x; + } else if (args.PROPERTY === "y position") { + currentValue = util.target.y; + } else if (args.PROPERTY === "direction") { + currentValue = util.target.direction; + } else if (args.PROPERTY === "size") { + currentValue = util.target.size; + } + + const value = this._tweenValue(args, util, "", "VALUE", currentValue); + + if (args.PROPERTY === "x position") { + util.target.setXY(value, util.target.y); + } else if (args.PROPERTY === "y position") { + util.target.setXY(util.target.x, value); + } else if (args.PROPERTY === "direction") { + util.target.setDirection(value); + } else if (args.PROPERTY === "size") { + util.target.setSize(value); + } + } + } + + Scratch.extensions.register(new Tween()); +})(Scratch); diff --git a/extensions/Lily/ClonesPlus.js b/extensions/Lily/ClonesPlus.js index 009e997692..cfd7b901f7 100644 --- a/extensions/Lily/ClonesPlus.js +++ b/extensions/Lily/ClonesPlus.js @@ -109,6 +109,7 @@ blockType: Scratch.BlockType.BOOLEAN, text: "touching main sprite?", filter: [Scratch.TargetType.SPRITE], + disableMonitor: true, }, "---", @@ -316,6 +317,7 @@ blockType: Scratch.BlockType.BOOLEAN, text: "is clone?", filter: [Scratch.TargetType.SPRITE], + disableMonitor: true, }, "---", diff --git a/extensions/Lily/CommentBlocks.js b/extensions/Lily/CommentBlocks.js index d2a25c4851..bba99b6571 100644 --- a/extensions/Lily/CommentBlocks.js +++ b/extensions/Lily/CommentBlocks.js @@ -38,6 +38,17 @@ }, }, }, + { + opcode: "commentC", + blockType: Scratch.BlockType.CONDITIONAL, + text: "// [COMMENT]", + arguments: { + COMMENT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "comment", + }, + }, + }, { opcode: "commentReporter", blockType: Scratch.BlockType.REPORTER, @@ -79,6 +90,10 @@ // no-op } + commentC(args, util) { + return true; + } + commentReporter(args) { return args.INPUT; } diff --git a/extensions/Lily/HackedBlocks.js b/extensions/Lily/HackedBlocks.js new file mode 100644 index 0000000000..f48bfe6ccf --- /dev/null +++ b/extensions/Lily/HackedBlocks.js @@ -0,0 +1,67 @@ +// Name: Hidden Block Collection +// ID: lmsHackedBlocks +// Description: Various "hacked blocks" that work in Scratch but are not visible in the palette. +// By: LilyMakesThings +// By: pumpkinhasapatch + +(function (Scratch) { + "use strict"; + + class HackedBlocks { + getInfo() { + return { + id: "lmsHackedBlocks", + name: "Hidden Blocks", + docsURI: "https://en.scratch-wiki.info/wiki/Hidden_Blocks#Events", + blocks: [ + // Use the sensing_touchingobjectmenu instead of event_ to also list sprites, since the block supports it + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + "---", + { + blockType: Scratch.BlockType.XML, + xml: '10', + }, + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + "---", + // Counting blocks that function similarly to variables + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + "---", + { + blockType: Scratch.BlockType.XML, + xml: '60', + }, + "---", + { + blockType: Scratch.BlockType.XML, + xml: '', + }, + // Dot matrix input from the micro:bit extension + // Returns a 5x5 binary grid of pixels depending on what was drawn. White pixels are 1 and green pixels are 0 + { + blockType: Scratch.BlockType.XML, + xml: '1111110101001000010001110', + }, + ], + }; + } + } + + Scratch.extensions.register(new HackedBlocks()); +})(Scratch); diff --git a/extensions/Lily/McUtils.js b/extensions/Lily/McUtils.js index 9e4d80758e..8cf8177d8d 100644 --- a/extensions/Lily/McUtils.js +++ b/extensions/Lily/McUtils.js @@ -2,6 +2,7 @@ // ID: lmsmcutils // Description: Helpful utilities for any fast food employee. // By: LilyMakesThings +// Context: Joke extension based on McDonalds, a fast food chain. /*! * Credit to NexusKitten (NamelessCat) for the idea @@ -105,7 +106,8 @@ } placeOrder(args, util) { - if (args.INPUT.includes("ice cream")) { + const text = Scratch.Cast.toString(args.INPUT); + if (text.includes("ice cream")) { return false; } else { return args.INPUT; diff --git a/extensions/Lily/MoreEvents.js b/extensions/Lily/MoreEvents.js index cd1f329b65..d0d40dbe65 100644 --- a/extensions/Lily/MoreEvents.js +++ b/extensions/Lily/MoreEvents.js @@ -79,6 +79,99 @@ var lastValues = {}; var runTimer = 0; + const MAX_BEFORE_SAVE_MS = 3000; + + const beforeSave = () => + new Promise((resolve) => { + const threads = vm.runtime.startHats("lmsMoreEvents_beforeSave"); + + if (threads.length === 0) { + resolve(); + return; + } + + const startTime = performance.now(); + const checkThreadStatus = () => { + if ( + performance.now() - startTime > MAX_BEFORE_SAVE_MS || + threads.every((thread) => !vm.runtime.isActiveThread(thread)) + ) { + vm.runtime.off("AFTER_EXECUTE", checkThreadStatus); + resolve(); + } + }; + + vm.runtime.on("AFTER_EXECUTE", checkThreadStatus); + }); + + const afterSave = () => { + // Wait until the next frame actually starts so that the actual file + // saving routine has a chance to finish before we starting running blocks. + vm.runtime.once("BEFORE_EXECUTE", () => { + vm.runtime.startHats("lmsMoreEvents_afterSave"); + }); + }; + + const originalSaveProjectSb3 = vm.saveProjectSb3; + vm.saveProjectSb3 = async function (...args) { + await beforeSave(); + const result = await originalSaveProjectSb3.apply(this, args); + afterSave(); + return result; + }; + + const originalSaveProjectSb3Stream = vm.saveProjectSb3Stream; + vm.saveProjectSb3Stream = function (...args) { + // This is complicated because we need to return a stream object syncronously... + + let realStream = null; + const queuedCalls = []; + + const whenStreamReady = (methodName, args) => { + if (realStream) { + return realStream[methodName].apply(realStream, args); + } else { + return new Promise((resolve) => { + queuedCalls.push({ + resolve, + methodName, + args, + }); + }); + } + }; + + const streamWrapper = { + on: (...args) => void whenStreamReady("on", args), + pause: (...args) => void whenStreamReady("pause", args), + resume: (...args) => void whenStreamReady("resume", args), + accumulate: (...args) => whenStreamReady("accumulate", args), + }; + + beforeSave().then(() => { + realStream = originalSaveProjectSb3Stream.apply(this, args); + + realStream.on("end", () => { + // Not sure how JSZip handles errors here, so we'll make sure not to break anything if + // afterSave somehow throws + try { + afterSave(); + } catch (e) { + console.error(e); + } + }); + + for (const queued of queuedCalls) { + queued.resolve( + realStream[queued.methodName].apply(realStream, queued.args) + ); + } + queuedCalls.length = 0; + }); + + return streamWrapper; + }; + class MoreEvents { constructor() { // Stop Sign Clicked contributed by @CST1229 @@ -354,6 +447,21 @@ blockType: Scratch.BlockType.XML, xml: '', }, + "---", + { + blockType: Scratch.BlockType.EVENT, + opcode: "beforeSave", + text: "before project saves", + shouldRestartExistingThreads: true, + isEdgeActivated: false, + }, + { + blockType: Scratch.BlockType.EVENT, + opcode: "afterSave", + text: "after project saves", + shouldRestartExistingThreads: true, + isEdgeActivated: false, + }, ], menus: { // Targets have acceptReporters: true diff --git a/extensions/Lily/SoundExpanded.js b/extensions/Lily/SoundExpanded.js new file mode 100644 index 0000000000..aefe95072b --- /dev/null +++ b/extensions/Lily/SoundExpanded.js @@ -0,0 +1,359 @@ +// Name: Sound Expanded +// Description: Adds more sound-related blocks. +// ID: lmsSoundExpanded +// By: LilyMakesThings + +(function (Scratch) { + "use strict"; + + const vm = Scratch.vm; + const runtime = vm.runtime; + const soundCategory = runtime.ext_scratch3_sound; + + class SoundExpanded { + getInfo() { + return { + id: "lmsSoundExpanded", + color1: "#CF63CF", + color2: "#C94FC9", + color3: "#BD42BD", + name: "Sound Expanded", + blocks: [ + { + opcode: "startLooping", + blockType: Scratch.BlockType.COMMAND, + text: "start looping [SOUND]", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0, + }, + }, + }, + { + opcode: "stopLooping", + blockType: Scratch.BlockType.COMMAND, + text: "end looping [SOUND]", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + { + opcode: "isLooping", + blockType: Scratch.BlockType.BOOLEAN, + text: "[SOUND] is looping?", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + + "---", + + { + opcode: "stopSound", + blockType: Scratch.BlockType.COMMAND, + text: "stop sound [SOUND]", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + { + opcode: "pauseSounds", + blockType: Scratch.BlockType.COMMAND, + text: "pause all sounds", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + { + opcode: "resumeSounds", + blockType: Scratch.BlockType.COMMAND, + text: "resume all sounds", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + + "---", + + { + opcode: "isSoundPlaying", + blockType: Scratch.BlockType.BOOLEAN, + text: "sound [SOUND] is playing?", + arguments: { + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + { + opcode: "attributeOfSound", + blockType: Scratch.BlockType.REPORTER, + text: "[ATTRIBUTE] of [SOUND]", + arguments: { + ATTRIBUTE: { + type: Scratch.ArgumentType.STRING, + menu: "attribute", + }, + SOUND: { + type: Scratch.ArgumentType.SOUND, + }, + }, + }, + { + opcode: "getSoundEffect", + blockType: Scratch.BlockType.REPORTER, + text: "[EFFECT] of [TARGET]", + arguments: { + EFFECT: { + type: Scratch.ArgumentType.STRING, + menu: "effect", + }, + TARGET: { + type: Scratch.ArgumentType.STRING, + menu: "targets", + }, + }, + }, + "---", + { + opcode: "setProjectVolume", + blockType: Scratch.BlockType.COMMAND, + text: "set project volume to [VALUE]%", + arguments: { + VALUE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100, + }, + }, + }, + { + opcode: "changeProjectVolume", + blockType: Scratch.BlockType.COMMAND, + text: "change project volume by [VALUE]", + arguments: { + VALUE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: -10, + }, + }, + }, + { + opcode: "getProjectVolume", + blockType: Scratch.BlockType.REPORTER, + text: "project volume", + }, + ], + menus: { + attribute: { + acceptReporters: false, + items: ["length", "channels", "sample rate", "dataURI"], + }, + effect: { + acceptReporters: false, + items: ["pitch", "pan"], + }, + targets: { + acceptReporters: true, + items: "_getTargets", + }, + }, + }; + } + + startLooping(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return 0; + const target = util.target; + const sprite = util.target.sprite; + + const soundId = sprite.sounds[index].soundId; + const soundPlayer = sprite.soundBank.soundPlayers[soundId]; + + if (!soundPlayer.isPlaying) { + soundCategory._addWaitingSound(target.id, soundId); + sprite.soundBank.playSound(util.target, soundId); + } + + if (!soundPlayer.outputNode) return; + soundPlayer.outputNode.loop = true; + } + + stopLooping(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return false; + const sprite = util.target.sprite; + + const soundId = sprite.sounds[index].soundId; + const soundPlayer = sprite.soundBank.soundPlayers[soundId]; + + if (!soundPlayer.outputNode) return; + soundPlayer.outputNode.loop = false; + } + + isLooping(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return false; + const sprite = util.target.sprite; + + const soundId = sprite.sounds[index].soundId; + const soundPlayer = sprite.soundBank.soundPlayers[soundId]; + + if (!soundPlayer.outputNode) return false; + return soundPlayer.outputNode.loop; + } + + stopSound(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return 0; + const target = util.target; + const sprite = target.sprite; + + const soundId = sprite.sounds[index].soundId; + const soundBank = sprite.soundBank; + soundBank.stop(target, soundId); + } + + pauseSounds(args, util) { + this._toggleSoundState(args, util, true); + } + + resumeSounds(args, util) { + this._toggleSoundState(args, util, false); + } + + _toggleSoundState(args, util, state) { + const sprite = util.target.sprite; + const audioContext = sprite.soundBank.audioEngine.audioContext; + + if (state) { + audioContext.suspend(); + return; + } else { + audioContext.resume(); + return; + } + } + + isSoundPlaying(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return false; + const sprite = util.target.sprite; + + const soundId = sprite.sounds[index].soundId; + const soundPlayers = sprite.soundBank.soundPlayers; + return soundPlayers[soundId].isPlaying; + } + + attributeOfSound(args, util) { + const index = this._getSoundIndex(args.SOUND, util); + if (index < 0) return 0; + const sprite = util.target.sprite; + + const sound = sprite.sounds[index]; + const soundId = sound.soundId; + const soundPlayer = sprite.soundBank.soundPlayers[soundId]; + const soundBuffer = soundPlayer.buffer; + + switch (args.ATTRIBUTE) { + case "length": + return Math.round(soundBuffer.duration * 100) / 100; + case "channels": + return soundBuffer.numberOfChannels; + case "sample rate": + return soundBuffer.sampleRate; + case "dataURI": + return sound.asset.encodeDataURI(); + } + } + + getSoundEffect(args, util) { + let target = Scratch.vm.runtime.getSpriteTargetByName(args.TARGET); + if (args.TARGET === "_myself_") target = util.target; + if (args.TARGET === "_stage_") target = runtime.getTargetForStage(); + const effects = target.soundEffects; + if (!effects) return 0; + return effects[args.EFFECT]; + } + + setProjectVolume(args) { + const value = Scratch.Cast.toNumber(args.VALUE); + const newVolume = this._wrapClamp(value / 100, 0, 1); + runtime.audioEngine.inputNode.gain.value = newVolume; + } + + changeProjectVolume(args) { + const value = Scratch.Cast.toNumber(args.VALUE) / 100; + const volume = runtime.audioEngine.inputNode.gain.value; + const newVolume = Scratch.Cast.toNumber( + Math.min(Math.max(volume + value, 1), 0) + ); + runtime.audioEngine.inputNode.gain.value = newVolume; + } + + getProjectVolume() { + const volume = runtime.audioEngine.inputNode.gain.value; + return Math.round(volume * 10000) / 100; + } + + /* Utility Functions */ + + _getSoundIndex(soundName, util) { + const len = util.target.sprite.sounds.length; + if (len === 0) { + return -1; + } + const index = this._getSoundIndexByName(soundName, util); + if (index !== -1) { + return index; + } + const oneIndexedIndex = parseInt(soundName, 10); + if (!isNaN(oneIndexedIndex)) { + return this._wrapClamp(oneIndexedIndex - 1, 0, len - 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; + } + } + return -1; + } + + _wrapClamp(n, min, max) { + const range = max - min + 1; + return n - Math.floor((n - min) / range) * range; + } + + _getTargets() { + let spriteNames = [ + { text: "myself", value: "_myself_" }, + { text: "Stage", value: "_stage_" }, + ]; + const targets = Scratch.vm.runtime.targets + .filter((target) => target.isOriginal && !target.isStage) + .map((target) => target.getName()); + spriteNames = spriteNames.concat(targets); + return spriteNames; + } + } + + Scratch.extensions.register(new SoundExpanded()); +})(Scratch); diff --git a/extensions/Lily/TempVariables2.js b/extensions/Lily/TempVariables2.js index 9b28309e35..a7dfb04db3 100644 --- a/extensions/Lily/TempVariables2.js +++ b/extensions/Lily/TempVariables2.js @@ -101,23 +101,21 @@ "---", - /* Add this when the compiler supports it { - opcode: 'forEachThreadVariable', + opcode: "forEachThreadVariable", blockType: Scratch.BlockType.LOOP, - text: 'for each [VAR] in [NUM]', + text: "for each [VAR] in [NUM]", arguments: { VAR: { type: Scratch.ArgumentType.STRING, - defaultValue: 'thread variable' + defaultValue: "thread variable", }, NUM: { type: Scratch.ArgumentType.NUMBER, - defaultValue: '10' - } - } + defaultValue: "10", + }, + }, }, - */ { opcode: "listThreadVariables", blockType: Scratch.BlockType.REPORTER, @@ -258,7 +256,7 @@ if (util.stackFrame.index < Number(args.NUM)) { util.stackFrame.index++; vars[args.VAR] = util.stackFrame.index; - util.startBranch(1, true); + return true; } } diff --git a/extensions/Lily/Video.js b/extensions/Lily/Video.js new file mode 100644 index 0000000000..828575d5e8 --- /dev/null +++ b/extensions/Lily/Video.js @@ -0,0 +1,496 @@ +// Name: Video +// ID: lmsVideo +// Description: Play videos from URLs. +// By: LilyMakesThings + +// Attribution is not required, but greatly appreciated. + +(function (Scratch) { + "use strict"; + + const vm = Scratch.vm; + const runtime = vm.runtime; + const renderer = vm.renderer; + const Cast = Scratch.Cast; + + const BitmapSkin = runtime.renderer.exports.BitmapSkin; + class VideoSkin extends BitmapSkin { + constructor(id, renderer, videoName, videoSrc) { + super(id, renderer); + + /** @type {string} */ + this.videoName = videoName; + + /** @type {string} */ + this.videoSrc = videoSrc; + + this.videoError = false; + + this.readyPromise = new Promise((resolve) => { + this.readyCallback = resolve; + }); + + this.videoElement = document.createElement("video"); + // Need to set non-zero dimensions, otherwise scratch-render thinks this is an empty image + this.videoElement.width = 1; + this.videoElement.height = 1; + this.videoElement.crossOrigin = "anonymous"; + this.videoElement.onloadeddata = () => { + // First frame loaded + this.readyCallback(); + this.markVideoDirty(); + }; + this.videoElement.onerror = () => { + this.videoError = true; + this.readyCallback(); + this.markVideoDirty(); + }; + this.videoElement.src = videoSrc; + this.videoElement.currentTime = 0; + + this.videoDirty = true; + + this.reuploadVideo(); + } + + reuploadVideo() { + this.videoDirty = false; + if (this.videoError) { + // Draw an image that looks similar to Scratch's normal costume loading errors + const canvas = document.createElement("canvas"); + canvas.width = this.videoElement.videoWidth || 128; + canvas.height = this.videoElement.videoHeight || 128; + const ctx = canvas.getContext("2d"); + + if (ctx) { + ctx.fillStyle = "#cccccc"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const fontSize = Math.min(canvas.width, canvas.height); + ctx.fillStyle = "#000000"; + ctx.font = `${fontSize}px serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillText("?", canvas.width / 2, canvas.height / 2); + } else { + // guess we can't draw the error then + } + + this.setBitmap(canvas); + } else { + this.setBitmap(this.videoElement); + } + } + + markVideoDirty() { + this.videoDirty = true; + this.emitWasAltered(); + } + + get size() { + if (this.videoDirty) { + this.reuploadVideo(); + } + return super.size; + } + + getTexture(scale) { + if (this.videoDirty) { + this.reuploadVideo(); + } + return super.getTexture(scale); + } + + dispose() { + super.dispose(); + this.videoElement.pause(); + } + } + + class Video { + constructor() { + /** @type {Record} */ + this.videos = Object.create(null); + + runtime.on("PROJECT_STOP_ALL", () => this.resetEverything()); + runtime.on("PROJECT_START", () => this.resetEverything()); + + runtime.on("BEFORE_EXECUTE", () => { + for (const skin of renderer._allSkins) { + if (skin instanceof VideoSkin && !skin.videoElement.paused) { + skin.markVideoDirty(); + } + } + }); + } + + getInfo() { + return { + id: "lmsVideo", + color1: "#557882", + name: "Video", + blocks: [ + { + blockType: Scratch.BlockType.XML, + xml: "