From 6fe5b85f8ce201d618a9c7c175d7502c2ff8c395 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 20 Dec 2019 17:46:21 -0400 Subject: begin the site! --- src/js/cli-playground.js | 21 +++ src/js/main.js | 222 ++++++++++++++++++++++++ src/js/sb3-gen.js | 433 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 676 insertions(+) create mode 100644 src/js/cli-playground.js create mode 100644 src/js/main.js create mode 100644 src/js/sb3-gen.js (limited to 'src/js') diff --git a/src/js/cli-playground.js b/src/js/cli-playground.js new file mode 100644 index 0000000..c5829c2 --- /dev/null +++ b/src/js/cli-playground.js @@ -0,0 +1,21 @@ +'use strict'; + +// Not actually included in the output bundle. + +const { scriptToScratchblocks } = require('./sb3-gen'); + +async function main() { + const fs = require('fs'); + const path = require('path'); + const readFile = util.promisify(fs.readFile); + const sb3 = process.argv[2]; + if (!sb3) { + console.error('Please pass an sb3 file.'); + process.exit(1); + } + const file = await readFile(sb3); + const project = await Project.fromSb3(file); + console.log(scriptToScratchblocks(project.sprites[1].scripts[0], project.sprites[1])); +} + +main().catch(err => console.error(err)); diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..3a3f823 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,222 @@ +'use strict'; + +require('../index.html'); +require('../css/style.css'); + +const { Project } = require('sb-edit'); +const { scriptToScratchblocks } = require('./sb3-gen'); + +// setup step + +const urlInput = document.getElementById('url-input'); +const fileInput = document.getElementById('file-input'); +const generateButton = document.getElementById('generate-button'); +const errorElement = document.getElementById('error-element'); +const downloadIndicator = document.getElementById('download-indicator'); + +function assetURL({md5, ext}) { + return 'https://assets.scratch.mit.edu/' + md5 + '.' + ext; +} + +function isUrlInputValid() { + const re = /^((.*:\/\/scratch.mit.edu\/projects\/[0-9]+\/?)|[0-9]+)(:.*)?$/; + return re.test(urlInput.value); +} + +urlInput.addEventListener('input', () => { + if (urlInput.value === '' || isUrlInputValid()) { + generateButton.classList.add('enabled'); + generateButton.classList.remove('disabled'); + } else { + generateButton.classList.remove('enabled'); + generateButton.classList.add('disabled'); + } +}); + +function clearChildren(el) { + while (el.firstChild) { + el.firstChild.remove(); + } +} + +function showError(msg) { + clearChildren(errorElement); + errorElement.appendChild(document.createTextNode(msg)); +} + +async function submitGenerate(evt) { + try { + if (!isUrlInputValid()) { + return; + } + + const url = urlInput.value; + const id = url.match(/[0-9]+/)[0]; + const result = await fetch('https://projects.scratch.mit.edu/' + id); + + let projectObj; + try { + projectObj = await result.json(); + } catch (error) { /* Scratch 1.4. */ } + if (!(projectObj && projectObj.meta && projectObj.meta.semver && projectObj.meta.semver.startsWith('3.'))) { + showError(`That project hasn't been saved in Scratch 3.0, so it won't work here.\nTry remixing it! That will make an up-to-date version.`); + return; + } + + let assetsDownloaded = 0; + const totalAssets = projectObj.targets.map(t => t.costumes.length + t.sounds.length).reduce((a, b) => a + b, 0); + const project = await Project.fromSb3JSON(projectObj, { + getAsset: async asset => { + const body = await fetch(assetURL(asset)); + const blob = await body.blob(); + assetsDownloaded++; + downloadIndicator.style.width = `${Math.round(assetsDownloaded / totalAssets * 100)}%`; + return blob; + } + }); + downloadIndicator.style.opacity = 0; + presentProject(project); + } catch (error) { + console.error(error); + showError(`Sorry, there was an error loading that project.`); + } +} + +async function submitFile() { + try { + if (!fileInput.files.length) { + return; + } + + const file = fileInput.files[0]; + const project = await Project.fromSb3(file); + presentProject(project); + } catch (error) { + console.error(error); + showError(`Sorry, there was an error loading that project.`); + } +} + +document.getElementById('form').addEventListener('submit', evt => { + evt.preventDefault(); + submitGenerate(); +}); + +function spaceEnterToClick(a) { + a.addEventListener('keydown', evt => { + if (evt.keyCode === 13 || evt.keyCode === 32) { + evt.target.click(); + } + }); +} + +for (const a of document.querySelectorAll('a.button')) { + spaceEnterToClick(a); +} + +document.getElementById('generate-button').addEventListener('click', submitGenerate); +fileInput.addEventListener('input', submitFile); + +if (location.hash.length > 1) { + urlInput.value = decodeURIComponent(location.hash.slice(1)); + submitGenerate(); +} + +// present step + +const targetList = document.getElementById('target-list'); +const scriptArea = document.getElementById('script-area'); + +let project; +let selectedTarget; +const sym = Symbol(); + +function presentTarget(targetName) { + if (!project) { + return; + } + + const target = [project.stage, ...project.sprites].find(t => t.name === targetName); + if (!target) { + return; + } + + if (target === selectedTarget) { + return; + } + + if (selectedTarget) { + if (!selectedTarget[sym]) { + selectedTarget[sym] = {}; + } + Object.assign(selectedTarget[sym], { + scrollLeft: scriptArea.scrollLeft, + scrollTop: scriptArea.scrollTop + }); + } + + selectedTarget = target; + + if (target[sym]) { + Object.assign(scriptArea, { + scrollLeft: target[sym].scrollLeft, + scrollTop: target[sym].scrollTop + }); + } else { + Object.assign(scriptArea, { + scrollLeft: 0, + scrollTop: 0 + }); + } + + for (const el of document.querySelectorAll('.target.selected')) { + el.classList.remove('selected'); + } + document.querySelector(`.target[data-name="${targetName.replace(/"/g, '\\"')}"]`).classList.add('selected'); + + clearChildren(scriptArea); + if (target.scripts.length) { + const offsetX = -target.scripts.reduce((least, s) => Math.min(least, s.x), target.scripts[0].x) + 240; + const offsetY = -target.scripts.reduce((least, s) => Math.min(least, s.y), target.scripts[0].y); + for (const script of target.scripts) { + const sb = scriptToScratchblocks(script, target); + const pre = document.createElement('pre'); + pre.classList.add('blocks'); + pre.appendChild(document.createTextNode(sb)); + pre.style.position = 'absolute'; + pre.style.left = (script.x + offsetX) + 'px'; + pre.style.top = (script.y + offsetY) + 'px'; + scriptArea.appendChild(pre); + } + } + + window.scratchblocks.renderMatching('pre.blocks', { + style: 'scratch3' + }); +} + +function presentProject(p) { + project = p; + + clearChildren(targetList); + for (const target of [project.stage, ...project.sprites]) { + const el = document.createElement('a'); + el.setAttribute('tabindex', 0); + el.classList.add('target'); + const img = document.createElement('img'); + img.src = assetURL(target.costumes[target.costumeNumber]); + el.appendChild(img); + const span = document.createElement('span'); + span.appendChild(document.createTextNode(target.name)); + el.appendChild(span); + el.dataset.name = target.name; + targetList.appendChild(el); + spaceEnterToClick(el); + el.addEventListener('click', () => presentTarget(target.name)); + } + + if (location.hash.includes(':')) { + const targetName = decodeURIComponent(location.hash.slice(location.hash.indexOf(':') + 1)); + presentTarget(targetName); + } +} diff --git a/src/js/sb3-gen.js b/src/js/sb3-gen.js new file mode 100644 index 0000000..615d73a --- /dev/null +++ b/src/js/sb3-gen.js @@ -0,0 +1,433 @@ +'use strict'; + +const { Project } = require('sb-edit'); +const util = require('util'); + +// TODO: set prefix to \t +function indent(string, prefix = ' ') { + return string.split('\n').map(l => prefix + l).join('\n'); +} + +function input(inp, target, flag = false) { + if (!inp) { + return ''; + } + const val = inp.value.replace ? inp.value.replace(/[()\[\]]|v$/g, m => '\\' + m) : inp.value; + switch (inp.type) { + case 'number': + case 'angle': + return `(${val})`; + + case 'string': + return `[${val}]`; + + case 'variable': + case 'list': + case 'graphicEffect': + case 'rotationStyle': + case 'greaterThanMenu': + case 'stopMenu': + case 'dragModeMenu': + case 'propertyOfMenu': + case 'currentMenu': + case 'mathopMenu': + case 'frontBackMenu': + case 'forwardBackwardMenu': + case 'costumeNumberName': + return `[${val} v]`; + + case 'costume': + case 'sound': + case 'goToTarget': + case 'pointTowardsTarget': + case 'penColorParam': + case 'target': + case 'cloneTarget': + case 'touchingTarget': + case 'distanceToMenu': + return `(${val} v)`; + + case 'broadcast': + case 'backdrop': + case 'key': + if (flag) { + return `[${val} v]`; + } else { + return `(${val} v)`; + } + + case 'color': + const hex = k => inp.value[k].toString(16).padStart(2, '0') + return `[#${hex('r') + hex('g') + hex('b')}]`; + + case 'block': + if (flag) { + return '\n' + indent(blockToScratchblocks(inp.value, target)) + '\n'; + } else { + return blockToScratchblocks(inp.value, target); + } + + case 'blocks': + return '\n' + indent(blocksToScratchblocks(inp.value, target)) + '\n'; + + default: + return `\x1b[33m<${inp.type}>\x1b[0m`; + } +} + +function blockToScratchblocks(block, target) { + if (!target) { + throw new Error('expected target'); + } + + const i = (key, ...args) => input(block.inputs[key], target, ...args); + const operator = op => `(${i('NUM1')} ${op} ${i('NUM2')})`; + const boolop = op => `<${i('OPERAND1')} ${op} ${i('OPERAND2')}>`; + + switch (block.opcode) { + + // motion ------------------------------------------------------ // + case 'motion_movesteps': + return `move ${i('STEPS')} steps`; + case 'motion_turnright': + return `turn cw ${i('DEGREES')} degrees`; + case 'motion_turnleft': + return `turn ccw ${i('DEGREES')} degrees`; + case 'motion_goto': + return `go to ${i('TO')}`; + case 'motion_gotoxy': + return `go to x: ${i('X')} y: ${i('Y')}`; + case `motion_glideto`: + return `glide ${i('SECS')} secs to ${i('TO')}`; + case `motion_glidesecstoxy`: + return `glide ${i('SECS')} secs to x: ${i('X')} y: ${i('Y')}`; + case 'motion_pointindirection': + return `point in direction ${i('DIRECTION')}`; + case 'motion_pointtowards': + return `point towards ${i('TOWARDS')}`; + case 'motion_changexby': + return `change x by ${i('DX')}`; + case 'motion_setx': + return `set x to ${i('X')}`; + case 'motion_changeyby': + return `change y by ${i('DY')}`; + case 'motion_sety': + return `set y to ${i('Y')}`; + case 'motion_ifonedgebounce': + return 'if on edge, bounce'; + case 'motion_setrotationstyle': + return `set rotation style ${i('STYLE')}`; + case 'motion_xposition': + return '(x position)'; + case 'motion_yposition': + return '(y position)'; + case 'motion_direction': + return '(direction)'; + + // looks ------------------------------------------------------- // + case 'looks_sayforsecs': + return `say ${i('MESSAGE')} for ${i('SECS')} seconds`; + case 'looks_say': + return `say ${i('MESSAGE')}`; + case 'looks_thinkforsecs': + return `think ${i('MESSAGE')} for ${i('SECS')} seconds`; + case 'looks_think': + return `think ${i('MESSAGE')}`; + case 'looks_switchcostumeto': + return `switch costume to ${i('COSTUME')}`; + case 'looks_nextcostume': + return 'next costume'; + case 'looks_switchbackdropto': + return `switch backdrop to ${i('BACKDROP')}`; + case 'looks_nextbackdrop': + return `next backdrop`; + case 'looks_changesizeby': + return `change size by ${i('CHANGE')}`; + case 'looks_setsizeto': + return `set size to ${i('SIZE')}%`; + case 'looks_changeeffectby': + return `change ${i('EFFECT')} effect by ${i('CHANGE')} :: looks`; + case 'looks_seteffectto': + return `set ${i('EFFECT')} effect to ${i('VALUE')} :: looks`; + case 'looks_cleargraphiceffects': + return 'clear graphic effects'; + case 'looks_show': + return 'show'; + case 'looks_hide': + return 'hide'; + case 'looks_gotofrontback': + return `go to ${i('FRONT_BACK')} layer`; + case 'looks_goforwardbackwardlayers': + return `go ${i('FORWARD_BACKWARD')} ${i('NUM')} layers`; + case 'looks_costumenumbername': + return `(costume ${i('NUMBER_NAME')})`; + case 'looks_backdropnumbername': + return `(backdrop ${i('NUMBER_NAME')})`; + case 'looks_size': + return '(size)'; + case 'looks_hideallsprites': + return 'hide all sprites :: looks'; + case 'looks_switchbackdroptoandwait': + return `switch backdrop to ${i('BACKDROP')} and wait`; + case 'looks_changestretchby': + return `change stretch by ${i('CHANGE')} :: looks`; + case 'looks_setstretchto': + return `set stretch to ${i('STRETCH')} % :: looks`; + + // sound ------------------------------------------------------- // + case 'sound_playuntildone': + return `play sound ${i('SOUND_MENU')} until done`; + case 'sound_play': + return `start sound ${i('SOUND_MENU')}`; + case 'sound_stopallsounds': + return 'stop all sounds'; + case 'sound_changeeffectby': + return `change ${i('EFFECT')} effect by ${i('VALUE')} :: sound`; + case 'sound_seteffectto': + return `set ${i('EFFECT')} effect to ${i('VALUE')} :: sound`; + case 'sound_cleareffects': + return 'clear sound effects'; + case 'sound_changevolumeby': + return `change volume by ${i('VOLUME')}`; + case 'sound_setvolumeto': + return `set volume to ${i('VOLUME')} %`; + case 'sound_volume': + return '(volume)'; + + // events ------------------------------------------------------ // + case 'event_whenflagclicked': + return 'when green flag clicked'; + case 'event_whenkeypressed': + return `when ${i('KEY_OPTION', true)} key pressed`; + case 'event_whenthisspriteclicked': + return 'when this sprite clicked'; + case 'event_whenbackdropswitchesto': + return `when backdrop switches to ${i('BACKDROP', true)}`; + case 'event_whengreaterthan': + return `when ${i('WHENGREATERTHANMENU')} > ${i('VALUE')}`; + case 'event_whenbroadcastreceived': + return `when I receive ${i('BROADCAST_OPTION', true)}`; + case 'event_broadcast': + return `broadcast ${i('BROADCAST_INPUT')}`; + case 'event_broadcastandwait': + return `broadcast ${i('BROADCAST_INPUT')} and wait`; + + // control ----------------------------------------------------- // + case 'control_wait': + return `wait ${i('DURATION')} seconds`; + case 'control_repeat': + return `repeat ${i('TIMES')}` + i('SUBSTACK', true) + 'end'; + case 'control_forever': + return 'forever' + i('SUBSTACK', true) + 'end'; + case 'control_if': + return `if ${i('CONDITION') || '<>'} then` + i('SUBSTACK', true) + 'end'; + case 'control_if_else': + return `if ${i('CONDITION') || '<>'} then` + i('SUBSTACK', true) + 'else' + i('SUBSTACK2', true) + 'end'; + case 'control_wait_until': + return `wait until ${i('CONDITION') || '<>'}`; + case 'control_repeat_until': + return `repeat until ${i('CONDITION') || '<>'}` + i('SUBSTACK', true) + 'end'; + case 'control_stop': + return `stop ${i('STOP_OPTION')}`; + case 'control_start_as_clone': + return 'when I start as a clone'; + case 'control_create_clone_of': + return `create clone of ${i('CLONE_OPTION')}`; + case 'control_delete_this_clone': + return 'delete this clone'; + + // sensing ----------------------------------------------------- // + case 'sensing_touchingobject': + return ``; + case 'sensing_touchingcolor': + return ``; + case 'sensing_coloristouchingcolor': + return `<${i('COLOR')} is touching ${i('COLOR2')} ?>`; + case 'sensing_distanceto': + return `(distance to ${i('DISTANCETOMENU')})`; + case 'sensing_askandwait': + return `ask ${i('QUESTION')} and wait`; + case 'sensing_answer': + return '(answer)'; + case 'sensing_keypressed': + return ``; + case 'sensing_mousedown': + return ''; + case 'sensing_mousex': + return '(mouse x)'; + case 'sensing_mousey': + return '(mouse y)'; + case 'sensing_setdragmode': + return `set drag mode ${i('DRAG_MODE')}`; + case 'sensing_loudness': + return '(loudness)'; + case 'sensing_loud': + return ''; + case 'sensing_timer': + return '(timer)'; + case 'sensing_resettimer': + return 'reset timer'; + case 'sensing_of': + return `(${i('PROPERTY')} of ${i('OBJECT')})`; + case 'sensing_current': + return `(current ${i('CURRENTMENU')})`; + case 'sensing_dayssince2000': + return '(days since 2000)'; + case 'sensing_username': + return '(username)'; + case 'sensing_userid': + return '(user id :: sensing)'; + + // operators --------------------------------------------------- // + case 'operator_add': + return operator('+'); + case 'operator_subtract': + return operator('-'); + case 'operator_multiply': + return operator('*'); + case 'operator_divide': + return operator('/'); + case 'operator_random': + return `(pick random ${i('FROM')} to ${i('TO')})`; + case 'operator_gt': + return boolop('>'); + case 'operator_lt': + return boolop('<'); + case 'operator_equals': + return boolop('='); + case 'operator_and': + return boolop('and'); + case 'operator_or': + return boolop('or'); + case 'operator_not': + return ``; + case 'operator_join': + return `(join ${i('STRING1')} ${i('STRING2')})`; + case 'operator_letter_of': + return `(letter ${i('LETTER')} of ${i('STRING')})`; + case 'operator_length': + return `(length of ${i('STRING')})`; + case 'operator_contains': + return `<${i('STRING1')} contains ${i('STRING2')} ? :: operators>`; + case 'operator_mod': + return operator('mod'); + case 'operator_round': + return `(round ${i('NUM')})`; + case 'operator_mathop': + return `(${i('OPERATOR')} of ${i('NUM')})`; + + // data -------------------------------------------------------- // + case 'data_variable': + return `(${block.inputs.VARIABLE.value} :: variables)`; + case 'data_setvariableto': + return `set ${i('VARIABLE')} to ${i('VALUE')}`; + case 'data_changevariableby': + return `change ${i('VARIABLE')} by ${i('VALUE')}`; + case 'data_showvariable': + return `show variable ${i('VARIABLE')}`; + case 'data_hidevariable': + return `hide variable ${i('VARIABLE')}`; + case 'data_listcontents': + return `(${block.inputs.LIST.value} :: list)`; + case 'data_addtolist': + return `add ${i('ITEM')} to ${i('LIST')}`; + case 'data_deleteoflist': + return `delete ${i('INDEX')} of ${i('LIST')}`; + case 'data_deletealloflist': + return `delete all of ${i('LIST')}`; + case 'data_insertatlist': + return `insert ${i('ITEM')} at ${i('INDEX')} of ${i('LIST')}`; + case 'data_replaceitemoflist': + return `replace item ${i('INDEX')} of ${i('LIST')} with ${i('ITEM')}`; + case 'data_itemoflist': + return `(item ${i('INDEX')} of ${i('LIST')})`; + case 'data_itemnumoflist': + return `(item # of ${i('ITEM')} in ${i('LIST')})`; + case 'data_lengthoflist': + return `(length of ${i('LIST')})`; + case 'data_listcontainsitem': + return `<${i('LIST')} contains ${i('ITEM')} ? :: list>`; + case 'data_showlist': + return `show list ${i('LIST')}`; + case 'data_hidelist': + return `hide list ${i('LIST')}`; + + // custom blocks ----------------------------------------------- // + case 'procedures_definition': + const spec = block.inputs.ARGUMENTS.value.map(({ type, name }) => { + switch (type) { + case 'label': + return name.replace(/\//g, '\\/'); + case 'numberOrString': + return `(${name})`; + case 'boolean': + return `<${name}>`; + } + }).join(' '); + return `define ${spec}` + (block.inputs.WARP.value ? ' // run without screen refresh' : ''); + case 'procedures_call': + const definition = target.scripts.map(s => s.blocks[0]).find(b => b.opcode === 'procedures_definition' && b.inputs.PROCCODE.value === block.inputs.PROCCODE.value); + if (definition) { + let index = 0; + return definition.inputs.ARGUMENTS.value.map(({ type, name }) => { + switch (type) { + case 'label': + return name.replace(/\//g, '\\/'); + default: + // TODO: deal with empty boolean inputs, which can't even load yet + return input(block.inputs.INPUTS.value[index++], target); + } + }).join(' ') + ' :: custom'; + } else { + return `... // missing custom block definition for ${block.inputs.PROCCODE.value}`; + } + case 'argument_reporter_string_number': + return `(${block.inputs.VALUE.value} :: custom-arg)`; + case 'argument_reporter_boolean': + return `<${block.inputs.VALUE.value} :: custom-arg>`; + + // extension: pen ---------------------------------------------- // + case 'pen_clear': + return 'erase all'; + case 'pen_stamp': + return 'stamp'; + case 'pen_penDown': + return 'pen down'; + case 'pen_penUp': + return 'pen up'; + case 'pen_setPenColorToColor': + return `set pen color to ${i('COLOR')}`; + case 'pen_changePenColorParamBy': + return `change pen ${i('COLOR_PARAM')} by ${i('VALUE')}`; + case 'pen_setPenColorParamTo': + return `set pen ${i('COLOR_PARAM')} to ${i('VALUE')}`; + case 'pen_changePenSizeBy': + return `change pen size by ${i('SIZE')}`; + case 'pen_setPenSizeTo': + return `set pen size to ${i('SIZE')}`; + case 'pen_changePenShadeBy': + return `change pen shade by ${i('SHADE')}`; + case 'pen_setPenShadeToNumber': + return `set pen shade to ${i('SHADE')}`; + case 'pen_changePenHueBy': + return `change pen color by ${i('HUE')}`; + case 'pen_setPenHueTo': + return `set pen hue to ${i('HUE')}`; + + default: + return `\x1b[33m${block.opcode} (${Object.keys(block.inputs).join(', ')})\x1b[0m`; + + } +} + +function blocksToScratchblocks(blocks, target) { + return blocks.map(b => blockToScratchblocks(b, target)).join('\n'); +} + +function scriptToScratchblocks(script, target) { + return blocksToScratchblocks(script.blocks, target); +} + +Object.assign(module.exports, { + scriptToScratchblocks +}); -- cgit 1.3.0-6-gf8a5