Spaces:
Sleeping
Sleeping
import VM from 'scratch-vm'; | |
import Renderer from 'scratch-render'; | |
import Storage from './storage'; | |
import AudioEngine from 'scratch-audio'; | |
import {BitmapAdapter} from 'scratch-svg-renderer'; | |
import JSZip from 'jszip'; | |
import {EventTarget} from '../common/event-target'; | |
import VideoProvider from './video'; | |
import Cloud from './cloud'; | |
import Question from './question'; | |
import {ListMonitor, VariableMonitor} from './monitor'; | |
import ControlBar from './control-bar'; | |
import {isValidListValue, isValidVariableValue} from './verify-value'; | |
import defaultMessages from './messages.json'; | |
import styles from './style.css'; | |
const getEventXY = (e) => { | |
if (e.touches && e.touches[0]) { | |
return {x: e.touches[0].clientX, y: e.touches[0].clientY}; | |
} else if (e.changedTouches && e.changedTouches[0]) { | |
return {x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY}; | |
} | |
return {x: e.clientX, y: e.clientY}; | |
}; | |
const wrapAsFunctionIfNotFunction = (value) => { | |
if (typeof value === 'function') { | |
return value; | |
} | |
return () => value; | |
}; | |
class Scaffolding extends EventTarget { | |
constructor () { | |
super(); | |
this.width = 480; | |
this.height = 360; | |
this.resizeMode = 'preserve-ratio'; | |
this.editableLists = false; | |
this.shouldConnectPeripherals = true; | |
this.usePackagedRuntime = false; | |
this.messages = defaultMessages; | |
this._monitors = new Map(); | |
this._mousedownPosition = null; | |
this._draggingId = null; | |
this._draggingStartMousePosition = null; | |
this._draggingStartSpritePosition = null; | |
this._offsetFromTop = 0; | |
this._offsetFromBottom = 0; | |
this._offsetFromLeft = 0; | |
this._offsetFromRight = 0; | |
this._root = document.createElement('div'); | |
this._root.className = styles.root; | |
this._layers = document.createElement('div'); | |
this._layers.className = styles.layers; | |
this._root.appendChild(this._layers); | |
this._canvas = document.createElement('canvas'); | |
this._canvas.className = styles.canvas; | |
this._addLayer(this._canvas); | |
this._overlays = document.createElement('div'); | |
this._overlays.className = styles.scaledOverlaysInner; | |
this._overlaysOuter = document.createElement('div'); | |
this._overlaysOuter.className = styles.scaledOverlaysOuter; | |
this._overlaysOuter.appendChild(this._overlays); | |
this._addLayer(this._overlaysOuter); | |
this._monitorOverlay = document.createElement('div'); | |
this._monitorOverlay.className = styles.monitorOverlay; | |
this._overlays.appendChild(this._monitorOverlay); | |
this._topControls = new ControlBar(); | |
this._layers.appendChild(this._topControls.root); | |
document.addEventListener('mousemove', this._onmousemove.bind(this)); | |
this._canvas.addEventListener('mousedown', this._onmousedown.bind(this)); | |
document.addEventListener('mouseup', this._onmouseup.bind(this)); | |
this._canvas.addEventListener('touchstart', this._ontouchstart.bind(this)); | |
document.addEventListener('touchmove', this._ontouchmove.bind(this)); | |
document.addEventListener('touchend', this._ontouchend.bind(this)); | |
this._canvas.addEventListener('contextmenu', this._oncontextmenu.bind(this)); | |
this._canvas.addEventListener('wheel', this._onwheel.bind(this)); | |
document.addEventListener('keydown', this._onkeydown.bind(this)); | |
document.addEventListener('keyup', this._onkeyup.bind(this)); | |
window.addEventListener('resize', this._onresize.bind(this)); | |
} | |
_addLayer (el) { | |
this._layers.appendChild(el); | |
} | |
_scratchCoordinates (x, y) { | |
return { | |
x: (this.width / this.layersRect.width) * (x - (this.layersRect.width / 2)), | |
y: -(this.height / this.layersRect.height) * (y - (this.layersRect.height / 2)) | |
}; | |
} | |
_onmousemove (e) { | |
const {x, y} = getEventXY(e); | |
const data = { | |
x: x - this.layersRect.left, | |
y: y - this.layersRect.top, | |
canvasWidth: this.layersRect.width, | |
canvasHeight: this.layersRect.height | |
}; | |
if (this._mousedownPosition && !this._draggingId) { | |
const distance = Math.sqrt( | |
Math.pow(data.x - this._mousedownPosition.x, 2) + | |
Math.pow(data.y - this._mousedownPosition.y, 2) | |
); | |
if (distance > 3) { | |
this._startDragging(data.x, data.y); | |
this._cancelDragTimeout(); | |
} | |
} else if (this._draggingId) { | |
const position = this._scratchCoordinates(data.x, data.y); | |
this.vm.postSpriteInfo({ | |
x: position.x - this._draggingStartMousePosition.x + this._draggingStartSpritePosition.x, | |
y: position.y - this._draggingStartMousePosition.y + this._draggingStartSpritePosition.y, | |
force: true | |
}); | |
} | |
this.vm.postIOData('mouse', data); | |
} | |
_startDragging (x, y) { | |
if (this._draggingId) return; | |
const drawableId = this.renderer.pick(x, y); | |
if (drawableId === null) return; | |
const targetId = this.vm.getTargetIdForDrawableId(drawableId); | |
if (targetId === null) return; | |
const target = this.vm.runtime.getTargetById(targetId); | |
if (!target.draggable) return; | |
target.goToFront(); | |
this._draggingId = targetId; | |
this._draggingStartMousePosition = this._scratchCoordinates(x, y); | |
this._draggingStartSpritePosition = { | |
x: target.x, | |
y: target.y | |
}; | |
this.vm.startDrag(targetId); | |
} | |
_cancelDragTimeout () { | |
clearTimeout(this._dragTimeout); | |
this._dragTimeout = null; | |
} | |
_onmousedown (e) { | |
const {x, y} = getEventXY(e); | |
const data = { | |
x: x - this.layersRect.left, | |
y: y - this.layersRect.top, | |
button: e.button, | |
canvasWidth: this.layersRect.width, | |
canvasHeight: this.layersRect.height, | |
isDown: true | |
}; | |
const isTouchEvent = typeof TouchEvent !== 'undefined' && e instanceof TouchEvent; | |
if (e.button === 0 || isTouchEvent) { | |
this._dragTimeout = setTimeout(this._startDragging.bind(this, data.x, data.y), 400); | |
} | |
if (isTouchEvent) { | |
e.preventDefault(); | |
if (document.activeElement && document.activeElement.blur) { | |
document.activeElement.blur(); | |
} | |
} | |
this._mousedownPosition = { | |
x: data.x, | |
y: data.y | |
}; | |
this.vm.postIOData('mouse', data); | |
} | |
_onmouseup (e) { | |
this._cancelDragTimeout(); | |
const {x, y} = getEventXY(e); | |
const data = { | |
x: x - this.layersRect.left, | |
y: y - this.layersRect.top, | |
button: e.button, | |
canvasWidth: this.layersRect.width, | |
canvasHeight: this.layersRect.height, | |
isDown: false, | |
wasDragged: this._draggingId !== null | |
}; | |
this._mousedownPosition = null; | |
this.vm.postIOData('mouse', data); | |
if (this._draggingId) { | |
this.vm.stopDrag(this._draggingId); | |
this._draggingStartMousePosition = null; | |
this._draggingStartSpritePosition = null; | |
this._draggingId = null; | |
} | |
} | |
_ontouchstart (e) { | |
this._onmousedown(e); | |
} | |
_ontouchmove (e) { | |
this._onmousemove(e); | |
} | |
_ontouchend (e) { | |
this._onmouseup(e); | |
} | |
_oncontextmenu (e) { | |
e.preventDefault(); | |
} | |
_onwheel (e) { | |
const data = { | |
deltaX: e.deltaX, | |
deltaY: e.deltaY | |
}; | |
this.vm.postIOData('mouseWheel', data); | |
} | |
_onkeydown (e) { | |
if (e.target !== document && e.target !== document.body) { | |
return; | |
} | |
const data = { | |
key: e.key, | |
keyCode: e.keyCode, | |
isDown: true | |
}; | |
this.vm.postIOData('keyboard', data); | |
if (e.keyCode === 32 || (e.keyCode >= 37 && e.keyCode <= 40) || e.keyCode === 8 || e.keyCode === 222 || e.keyCode === 191) { | |
e.preventDefault(); | |
} | |
} | |
_onkeyup (e) { | |
const data = { | |
key: e.key, | |
keyCode: e.keyCode, | |
isDown: false | |
}; | |
this.vm.postIOData('keyboard', data); | |
if (e.target !== document && e.target !== document.body) { | |
e.preventDefault(); | |
} | |
} | |
_onresize () { | |
this.relayout(); | |
} | |
relayout () { | |
const totalWidth = Math.max(1, this._root.offsetWidth); | |
const totalHeight = Math.max(1, this._root.offsetHeight); | |
const offsetFromTop = this._offsetFromTop + this._topControls.computeHeight(); | |
const offsetFromBottom = this._offsetFromBottom; | |
const offsetFromLeft = this._offsetFromLeft; | |
const offsetFromRight = this._offsetFromRight; | |
const projectAreaWidth = Math.max(1, totalWidth - offsetFromLeft - offsetFromRight); | |
const projectAreaHeight = Math.max(1, totalHeight - offsetFromTop - offsetFromBottom); | |
if (this.resizeMode === 'dynamic-resize') { | |
// setStageSize is a TurboWarp-specific method | |
if (this.vm.setStageSize) { | |
this.width = projectAreaWidth; | |
this.height = projectAreaHeight; | |
this.vm.setStageSize(this.width, this.height); | |
} else { | |
console.warn('dynamic-resize not supported: vm does not implement setStageSize'); | |
} | |
} | |
let width = projectAreaWidth; | |
let height = projectAreaHeight; | |
if (this.resizeMode !== 'stretch') { | |
width = height / this.height * this.width; | |
if (width > projectAreaWidth) { | |
height = projectAreaWidth / this.width * this.height; | |
width = projectAreaWidth; | |
} | |
} | |
const distanceFromTop = totalHeight - height; | |
const distanceFromLeft = totalWidth - width; | |
const translateY = (distanceFromLeft - offsetFromLeft - offsetFromRight) / 2 + offsetFromLeft - (distanceFromLeft / 2); | |
const translateX = (distanceFromTop - offsetFromTop - offsetFromBottom) / 2 + offsetFromTop - (distanceFromTop / 2); | |
this._layers.style.transform = `translate(${translateY}px, ${translateX}px)`; | |
this._layers.style.width = `${width}px`; | |
this._layers.style.height = `${height}px`; | |
this._overlays.style.transform = `scale(${width / this.width}, ${height / this.height})`; | |
this.renderer.resize(width, height); | |
this.layersRect = this._layers.getBoundingClientRect(); | |
} | |
appendTo (element) { | |
element.appendChild(this._root); | |
this.relayout(); | |
} | |
setup () { | |
this.vm = new VM(); | |
this.vm.setCompatibilityMode(true); | |
this.vm.setLocale(navigator.language); | |
this.vm.on('MONITORS_UPDATE', this._onmonitorsupdate.bind(this)); | |
this.vm.runtime.on('QUESTION', this._onquestion.bind(this)); | |
this.vm.on('PROJECT_RUN_START', () => this.dispatchEvent(new Event('PROJECT_RUN_START'))); | |
this.vm.on('PROJECT_RUN_STOP', () => this.dispatchEvent(new Event('PROJECT_RUN_STOP'))); | |
// TurboWarp-specific VM extensions | |
if (this.usePackagedRuntime && this.vm.convertToPackagedRuntime) { | |
this.vm.convertToPackagedRuntime(); | |
} | |
if (this.vm.setStageSize) { | |
this.vm.setStageSize(this.width, this.height); | |
} | |
if (this.vm.runtime.cloudOptions) { | |
this.vm.runtime.cloudOptions.limit = Infinity; | |
} | |
// TODO: remove when https://github.com/TurboWarp/packager/issues/213 is fixed | |
this.vm.on('STAGE_SIZE_CHANGED', (width, height) => { | |
if (this.width !== width || this.height !== height) { | |
this.width = width; | |
this.height = height; | |
this.relayout(); | |
} | |
}); | |
this.cloudManager = new Cloud.CloudManager(this); | |
this.renderer = new Renderer( | |
this._canvas, | |
-this.width / 2, | |
this.width / 2, | |
-this.height / 2, | |
this.height / 2 | |
); | |
this.vm.attachRenderer(this.renderer); | |
// TurboWarp-specific renderer extensions | |
if (this.renderer.overlayContainer) { | |
this._layers.insertBefore(this.renderer.overlayContainer, this._overlaysOuter); | |
} | |
this.storage = new Storage(); | |
this.vm.attachStorage(this.storage); | |
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') { | |
this.audioEngine = new AudioEngine(); | |
this.vm.attachAudioEngine(this.audioEngine); | |
} else { | |
console.warn('AudioContext not supported. Sound will not work.'); | |
} | |
this.bitmapAdapter = new BitmapAdapter(); | |
this.vm.attachV2BitmapAdapter(this.bitmapAdapter); | |
this.videoProvider = new VideoProvider(); | |
this.vm.setVideoProvider(this.videoProvider); | |
} | |
async _connectPeripherals () { | |
const scanExtension = (extensionId) => new Promise((resolve) => { | |
const onListUpdate = (peripherals) => { | |
const peripheralArray = Object.keys(peripherals).map((id) => peripherals[id]); | |
if (peripheralArray.length > 0) { | |
const peripheral = peripheralArray[0]; | |
console.log('Connecting to peripheral', peripheral); | |
this.vm.connectPeripheral(extensionId, peripheral.peripheralId); | |
} else { | |
console.error('No peripherals found for', extensionId); | |
} | |
done(); | |
}; | |
const onScanTimeout = () => { | |
console.error('Peripheral scan timed out for', extensionId); | |
done(); | |
}; | |
const done = () => { | |
this.vm.removeListener('PERIPHERAL_LIST_UPDATE', onListUpdate); | |
this.vm.removeListener('PERIPHERAL_SCAN_TIMEOUT', onScanTimeout); | |
resolve(); | |
}; | |
this.vm.on('PERIPHERAL_LIST_UPDATE', onListUpdate); | |
this.vm.on('PERIPHERAL_SCAN_TIMEOUT', onScanTimeout); | |
this.vm.scanForPeripheral(extensionId); | |
}); | |
for (const extensionId of Object.keys(this.vm.runtime.peripheralExtensions)) { | |
await scanExtension(extensionId); | |
} | |
} | |
_onmonitorsupdate (monitors) { | |
for (const monitorData of monitors.valueSeq()) { | |
const id = monitorData.get('id'); | |
if (!this._monitors.has(id)) { | |
const visible = monitorData.get('visible'); | |
if (!visible) { | |
// Would be a waste to make it now | |
continue; | |
} | |
// TODO: add to DOM in same order as appears in list | |
const mode = monitorData.get('mode'); | |
if (mode === 'list') { | |
this._monitors.set(id, new ListMonitor(this, monitorData)); | |
} else { | |
this._monitors.set(id, new VariableMonitor(this, monitorData)); | |
} | |
} | |
const monitorObject = this._monitors.get(id); | |
monitorObject.update(monitorData); | |
} | |
} | |
ask (text) { | |
this._question = new Question(this, text); | |
return this._question.answer(); | |
} | |
_onquestion (question) { | |
if (this._question) { | |
this._question.destroy() | |
} | |
if (question !== null) { | |
this.ask(question) | |
.then((answer) => { | |
this.vm.runtime.emit('ANSWER', answer); | |
}); | |
} | |
} | |
loadProject (data) { | |
return this.vm.loadProject(data) | |
.then(() => { | |
this.vm.setCloudProvider(this.cloudManager); | |
this.cloudManager.projectReady(); | |
this.renderer.draw(); | |
// Render again after a short delay because some costumes are loaded async | |
setTimeout(() => { | |
this.renderer.draw(); | |
}); | |
if (this.shouldConnectPeripherals) { | |
this._connectPeripherals(); | |
} | |
}); | |
} | |
setUsername (username) { | |
this._username = username; | |
this.vm.postIOData('userData', { | |
username | |
}); | |
} | |
addCloudProvider (provider) { | |
this.cloudManager.addProvider(provider); | |
} | |
addCloudProviderOverride (name, provider) { | |
this.cloudManager.addProviderOverride(name, provider); | |
} | |
addControlButton({element, where}) { | |
if (where === 'top-left') { | |
this._topControls.addToStart(element); | |
} else if (where === 'top-right') { | |
this._topControls.addToEnd(element); | |
} else { | |
throw new Error(`Unknown 'where': ${where}`); | |
} | |
this.relayout(); | |
} | |
getMessage (id) { | |
return this.messages[id] || id; | |
} | |
/** | |
* Change primary accent color. | |
* @param {string} color Color in the format #abcdef | |
*/ | |
setAccentColor (color) { | |
this._root.style.setProperty('--sc-accent-color', color); | |
this._root.style.setProperty('--sc-accent-color-transparent', `${color}59`); | |
} | |
start () { | |
this.vm.start(); | |
this.vm.greenFlag(); | |
} | |
greenFlag () { | |
this.start(); | |
} | |
stopAll () { | |
this.vm.stopAll(); | |
} | |
_lookupVariable(name, type) { | |
const variable = this.vm.runtime.getTargetForStage().lookupVariableByNameAndType(name, type); | |
if (!variable) throw new Error(`Global ${type || 'variable'} does not exist: ${name}`); | |
return variable; | |
} | |
setExtensionSecurityManager (newManager) { | |
const securityManager = this.vm.extensionManager.securityManager; | |
if (!securityManager) { | |
console.warn('setExtensionSecurityManager not supported: there is no security manager'); | |
return; | |
} | |
for (const [methodName, fn] of Object.entries(newManager)) { | |
securityManager[methodName] = wrapAsFunctionIfNotFunction(fn); | |
} | |
} | |
getVariable (name) { | |
return this._lookupVariable(name, '').value; | |
} | |
setVariable(name, value) { | |
if (!isValidVariableValue(value)) { | |
throw new Error('Invalid variable value'); | |
} | |
this._lookupVariable(name, '').value = value; | |
} | |
getList(name) { | |
return this._lookupVariable(name, 'list').value; | |
} | |
setList(name, value) { | |
if (!isValidListValue(value)) { | |
throw new Error('Invalid list value'); | |
} | |
this._lookupVariable(name, 'list').value = value; | |
} | |
} | |
export { | |
Scaffolding, | |
Cloud, | |
VM, | |
Renderer, | |
Storage, | |
AudioEngine, | |
JSZip | |
}; | |