Merge pull request from LLK/develop

Release 0.1.0
This commit is contained in:
Andrew Sliwinski 2016-05-10 12:44:38 -04:00
commit 852690d7cb
27 changed files with 15414 additions and 26 deletions

18
.eslintrc Normal file
View file

@ -0,0 +1,18 @@
{
"rules": {
"curly": [2, "multi-line"],
"eol-last": [2],
"indent": [2, 4],
"quotes": [2, "single"],
"linebreak-style": [2, "unix"],
"max-len": [2, 80, 4],
"semi": [2, "always"],
"strict": [2, "never"],
"no-console": [2, {"allow": ["log", "warn", "error"]}]
},
"env": {
"node": true,
"browser": true
},
"extends": "eslint:recommended"
}

5
.npmrc
View file

@ -1,5 +0,0 @@
engine-strict=true
save-exact=true
save-prefix=~
init-license=BSD-3-Clause
init-author-name=Massachusetts Institute of Technology

8
.travis.yml Normal file
View file

@ -0,0 +1,8 @@
language: node_js
node_js:
- "4"
- "stable"
sudo: false
cache:
directories:
- node_modules

View file

@ -1,12 +1,21 @@
ESLINT=./node_modules/.bin/eslint
NODE=node
TAP=./node_modules/.bin/tap
WEBPACK=./node_modules/.bin/webpack --progress --colors
# ------------------------------------------------------------------------------
build:
$(WEBPACK)
watch:
$(WEBPACK) --watch
# ------------------------------------------------------------------------------
lint:
$(ESLINT) ./*.js
$(ESLINT) ./lib/*.js
$(ESLINT) ./src/*.js
$(ESLINT) ./src/**/*.js
$(ESLINT) ./test/**/*.js
test:
@ -16,9 +25,6 @@ test:
coverage:
$(TAP) ./test/{unit,integration}/*.js --coverage --coverage-report=lcov
benchmark:
$(NODE) ./test/benchmark/performance.js
# ------------------------------------------------------------------------------
.PHONY: lint test coverage benchmark
.PHONY: build lint test coverage benchmark

View file

@ -1,13 +1,84 @@
## scratch-vm
#### Scratch VM is a library for representing, running, and maintaining the state of computer programs written using [Scratch Blocks](https://github.com/LLK/scratch-blocks).
[![Build Status](https://travis-ci.com/LLK/scratch-vm.svg?token=xzzHj4ct3SyBTpeqxnx1&branch=develop)](https://travis-ci.com/LLK/scratch-vm)
## Installation
```bash
npm install scratch-vm
```
## Integration
## Setup
```js
var VirtualMachine = require('scratch-vm');
var vm = new VirtualMachine();
// Block events
workspace.addChangeListener(function(e) {
// Handle "tapping" a block
if (e instanceof Blockly.Events.Ui && e.element === 'click') {
var stackBlock = workspace.getBlockById(e.blockId).getRootBlock().id;
vm.runtime.toggleStack(stackBlock);
// Otherwise, pass along to the block listener
} else {
vm.blockListener(e);
}
});
// Run threads
vm.runtime.start();
```
## Standalone Build
```bash
make build
```
```html
<script src="/path/to/vm.js"></script>
<script>
var vm = new window.VirtualMachine();
// do things
</script>
```
## Abstract Syntax Tree
#### Overview
The Virtual Machine constructs and maintains the state of an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) (AST) by listening to events emitted by the [scratch-blocks](https://github.com/LLK/scratch-blocks) workspace via the `blockListener`. At any time, the current state of the AST can be viewed by inspecting the `vm.runtime.blocks` object.
#### Anatomy of a Block
```json
{
"id": "^1r~63Gdl7;Dh?I*OP3_",
"opcode": "wedo_motorclockwise",
"next": null,
"fields": {
"DURATION": {
"name": "DURATION",
"value": null,
"blocks": {
"1?P=eV(OiDY3vMk!24Ip": {
"id": "1?P=eV(OiDY3vMk!24Ip",
"opcode": "math_number",
"next": null,
"fields": {
"NUM": {
"name": "NUM",
"value": "10",
"blocks": null
}
}
}
}
},
"SUBSTACK": {
"name": "SUBSTACK",
"value": "@1ln(HsUO4!]*2*%BrE|",
"blocks": null
}
}
}
```
## Testing
@ -19,6 +90,5 @@ make test
make coverage
```
```bash
make benchmark
```
## Donation
We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, community, and resource development efforts. Donations of any size are appreciated. Thank you!

View file

View file

@ -1,7 +1,7 @@
{
"name": "scratch-vm",
"version": "1.0.0",
"description": "",
"version": "0.1.0",
"description": "Virtual Machine for Scratch 3.0",
"author": "Massachusetts Institute of Technology",
"license": "BSD-3-Clause",
"homepage": "https://github.com/LLK/scratch-vm#readme",
@ -9,13 +9,18 @@
"type": "git",
"url": "git+ssh://git@github.com/LLK/scratch-vm.git"
},
"main": "index.js",
"main": "./src/index.js",
"scripts": {
"test": "make test"
},
"dependencies": {
"htmlparser2": "3.9.0",
"memoizee": "0.3.10"
},
"devDependencies": {
"benchmark": "2.1.0",
"eslint": "2.7.0",
"tap": "5.7.1"
"json-loader": "0.5.4",
"tap": "5.7.1",
"webpack": "1.13.0"
}
}

82
src/blocks/scratch3.js Normal file
View file

@ -0,0 +1,82 @@
function Scratch3Blocks(runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {Object.<string, Function>} Mapping of opcode to Function.
*/
Scratch3Blocks.prototype.getPrimitives = function() {
return {
'control_repeat': this.repeat,
'control_forever': this.forever,
'control_wait': this.wait,
'control_stop': this.stop,
'event_whenflagclicked': this.whenFlagClicked,
'event_whenbroadcastreceived': this.whenBroadcastReceived,
'event_broadcast': this.broadcast
};
};
Scratch3Blocks.prototype.repeat = function(argValues, util) {
console.log('Running: control_repeat');
// Initialize loop
if (util.stackFrame.loopCounter === undefined) {
util.stackFrame.loopCounter = parseInt(argValues[0]); // @todo arg
}
// Decrease counter
util.stackFrame.loopCounter--;
// If we still have some left, start the substack
if (util.stackFrame.loopCounter >= 0) {
util.startSubstack();
}
};
Scratch3Blocks.prototype.forever = function(argValues, util) {
console.log('Running: control_forever');
util.startSubstack();
};
Scratch3Blocks.prototype.wait = function(argValues, util) {
console.log('Running: control_wait');
util.yield();
util.timeout(function() {
util.done();
}, 1000 * parseFloat(argValues[0]));
};
Scratch3Blocks.prototype.stop = function() {
console.log('Running: control_stop');
// @todo - don't use this.runtime
this.runtime.stopAll();
};
Scratch3Blocks.prototype.whenFlagClicked = function() {
console.log('Running: event_whenflagclicked');
// No-op
};
Scratch3Blocks.prototype.whenBroadcastReceived = function() {
console.log('Running: event_whenbroadcastreceived');
// No-op
};
Scratch3Blocks.prototype.broadcast = function(argValues, util) {
console.log('Running: event_broadcast');
util.startHats(function(hat) {
if (hat.opcode === 'event_whenbroadcastreceived') {
var shadows = hat.fields.CHOICE.blocks;
for (var sb in shadows) {
var shadowblock = shadows[sb];
return shadowblock.fields.CHOICE.value === argValues[0];
}
}
return false;
});
};
module.exports = Scratch3Blocks;

154
src/blocks/wedo2.js Normal file
View file

@ -0,0 +1,154 @@
var YieldTimers = require('../util/yieldtimers.js');
function WeDo2Blocks(runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* Current motor speed, as a percentage (100 = full speed).
* @type {number}
* @private
*/
this._motorSpeed = 100;
/**
* The timeout ID for a pending motor action.
* @type {?int}
* @private
*/
this._motorTimeout = null;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {Object.<string, Function>} Mapping of opcode to Function.
*/
WeDo2Blocks.prototype.getPrimitives = function() {
return {
'wedo_motorclockwise': this.motorClockwise,
'wedo_motorcounterclockwise': this.motorCounterClockwise,
'wedo_motorspeed': this.motorSpeed,
'wedo_setcolor': this.setColor,
'wedo_whendistanceclose': this.whenDistanceClose,
'wedo_whentilt': this.whenTilt
};
};
/**
* Clamp a value between a minimum and maximum value.
* @todo move this to a common utility class.
* @param val The value to clamp.
* @param min The minimum return value.
* @param max The maximum return value.
* @returns {number} The clamped value.
* @private
*/
WeDo2Blocks.prototype._clamp = function(val, min, max) {
return Math.max(min, Math.min(val, max));
};
/**
* Common implementation for motor blocks.
* @param direction The direction to turn ('left' or 'right').
* @param durationSeconds The number of seconds to run.
* @param util The util instance to use for yielding and finishing.
* @private
*/
WeDo2Blocks.prototype._motorOnFor = function(direction, durationSeconds, util) {
if (this._motorTimeout > 0) {
// @todo maybe this should go through util
YieldTimers.resolve(this._motorTimeout);
this._motorTimeout = null;
}
if (window.native) {
window.native.motorRun(direction, this._motorSpeed);
}
var instance = this;
var myTimeout = this._motorTimeout = util.timeout(function() {
if (instance._motorTimeout == myTimeout) {
instance._motorTimeout = null;
}
if (window.native) {
window.native.motorStop();
}
util.done();
}, 1000 * durationSeconds);
util.yield();
};
WeDo2Blocks.prototype.motorClockwise = function(argValues, util) {
this._motorOnFor('right', parseFloat(argValues[0]), util);
};
WeDo2Blocks.prototype.motorCounterClockwise = function(argValues, util) {
this._motorOnFor('left', parseFloat(argValues[0]), util);
};
WeDo2Blocks.prototype.motorSpeed = function(argValues) {
var speed = argValues[0];
switch (speed) {
case 'slow':
this._motorSpeed = 20;
break;
case 'medium':
this._motorSpeed = 50;
break;
case 'fast':
this._motorSpeed = 100;
break;
}
};
/**
* Convert a color name to a WeDo color index.
* Supports 'mystery' for a random hue.
* @param colorName The color to retrieve.
* @returns {number} The WeDo color index.
* @private
*/
WeDo2Blocks.prototype._getColor = function(colorName) {
var colors = {
'yellow': 7,
'orange': 8,
'coral': 9,
'magenta': 1,
'purple': 2,
'blue': 3,
'green': 6,
'white': 10
};
if (colorName == 'mystery') {
return Math.floor((Math.random() * 10) + 1);
}
return colors[colorName];
};
WeDo2Blocks.prototype.setColor = function(argValues, util) {
if (window.native) {
var colorIndex = this._getColor(argValues[0]);
window.native.setLedColor(colorIndex);
}
// Pause for quarter second
util.yield();
util.timeout(function() {
util.done();
}, 250);
};
WeDo2Blocks.prototype.whenDistanceClose = function() {
console.log('Running: wedo_whendistanceclose');
};
WeDo2Blocks.prototype.whenTilt = function() {
console.log('Running: wedo_whentilt');
};
module.exports = WeDo2Blocks;

87
src/engine/adapter.js Normal file
View file

@ -0,0 +1,87 @@
var html = require('htmlparser2');
var memoize = require('memoizee');
var parseDOM = memoize(html.parseDOM, {
length: 1,
resolvers: [String],
max: 200
});
/**
* Adapter between block creation events and block representation which can be
* used by the Scratch runtime.
*
* @param {Object} `Blockly.events.create`
*
* @return {Object}
*/
module.exports = function (e) {
// Validate input
if (typeof e !== 'object') return;
if (typeof e.blockId !== 'string') return;
if (typeof e.xml !== 'object') return;
// Storage object
var obj = {
id: e.blockId,
opcode: null,
next: null,
fields: {}
};
// Set opcode
if (typeof e.xml.attributes === 'object') {
obj.opcode = e.xml.attributes.type.value;
}
// Extract fields from event's `innerHTML`
if (typeof e.xml.innerHTML !== 'string') return obj;
if (e.xml.innerHTML === '') return obj;
obj.fields = extract(parseDOM(e.xml.innerHTML));
return obj;
};
/**
* Extracts fields from a block's innerHTML.
* @todo Extend this to support vertical grammar / nested blocks.
*
* @param {Object} DOM representation of block's innerHTML
*
* @return {Object}
*/
function extract (dom) {
// Storage object
var fields = {};
// Field
var field = dom[0];
var fieldName = field.attribs.name;
fields[fieldName] = {
name: fieldName,
value: null,
blocks: {}
};
// Shadow block
var shadow = field.children[0];
var shadowId = shadow.attribs.id;
var shadowOpcode = shadow.attribs.type;
fields[fieldName].blocks[shadowId] = {
id: shadowId,
opcode: shadowOpcode,
next: null,
fields: {}
};
// Primitive
var primitive = shadow.children[0];
var primitiveName = primitive.attribs.name;
var primitiveValue = primitive.children[0].data;
fields[fieldName].blocks[shadowId].fields[primitiveName] = {
name: primitiveName,
value: primitiveValue,
blocks: null
};
return fields;
}

402
src/engine/runtime.js Normal file
View file

@ -0,0 +1,402 @@
var EventEmitter = require('events');
var Sequencer = require('./sequencer');
var Thread = require('./thread');
var util = require('util');
var defaultBlockPackages = {
'scratch3': require('../blocks/scratch3'),
'wedo2': require('../blocks/wedo2')
};
/**
* Manages blocks, stacks, and the sequencer.
*/
function Runtime () {
// Bind event emitter
EventEmitter.call(this);
// State for the runtime
/**
* All blocks in the workspace.
* Keys are block IDs, values are metadata about the block.
* @type {Object.<string, Object>}
*/
this.blocks = {};
/**
* All stacks in the workspace.
* A list of block IDs that represent stacks (first block in stack).
* @type {Array.<String>}
*/
this.stacks = [];
/**
* A list of threads that are currently running in the VM.
* Threads are added when execution starts and pruned when execution ends.
* @type {Array.<Thread>}
*/
this.threads = [];
/** @type {!Sequencer} */
this.sequencer = new Sequencer(this);
/**
* Map to look up a block primitive's implementation function by its opcode.
* This is a two-step lookup: package name first, then primitive name.
* @type {Object.<string, Function>}
*/
this._primitives = {};
this._registerBlockPackages();
}
/**
* Event name for glowing a stack
* @const {string}
*/
Runtime.STACK_GLOW_ON = 'STACK_GLOW_ON';
/**
* Event name for unglowing a stack
* @const {string}
*/
Runtime.STACK_GLOW_OFF = 'STACK_GLOW_OFF';
/**
* Event name for glowing a block
* @const {string}
*/
Runtime.BLOCK_GLOW_ON = 'BLOCK_GLOW_ON';
/**
* Event name for unglowing a block
* @const {string}
*/
Runtime.BLOCK_GLOW_OFF = 'BLOCK_GLOW_OFF';
/**
* Inherit from EventEmitter
*/
util.inherits(Runtime, EventEmitter);
/**
* How rapidly we try to step threads, in ms.
*/
Runtime.THREAD_STEP_INTERVAL = 1000 / 30;
/**
* Block management: create blocks and stacks from a `create` event
* @param {!Object} block Blockly create event to be processed
*/
Runtime.prototype.createBlock = function (block, opt_isFlyoutBlock) {
// Create new block
this.blocks[block.id] = block;
// Walk each field and add any shadow blocks
// @todo Expand this to cover vertical / nested blocks
for (var i in block.fields) {
var shadows = block.fields[i].blocks;
for (var y in shadows) {
var shadow = shadows[y];
this.blocks[shadow.id] = shadow;
}
}
// Push block id to stacks array. New blocks are always a stack even if only
// momentary. If the new block is added to an existing stack this stack will
// be removed by the `moveBlock` method below.
if (!opt_isFlyoutBlock) {
this.stacks.push(block.id);
}
};
/**
* Block management: change block field values
* @param {!Object} args Blockly change event to be processed
*/
Runtime.prototype.changeBlock = function (args) {
// Validate
if (args.element !== 'field') return;
if (typeof this.blocks[args.id] === 'undefined') return;
if (typeof this.blocks[args.id].fields[args.name] === 'undefined') return;
// Update block value
this.blocks[args.id].fields[args.name].value = args.value;
};
/**
* Block management: move blocks from parent to parent
* @param {!Object} e Blockly move event to be processed
*/
Runtime.prototype.moveBlock = function (e) {
var _this = this;
// Block was removed from parent
if (e.newParent === undefined && e.oldParent !== undefined) {
// Add stack
_this.stacks.push(e.id);
// Update old parent
if (e.oldField === undefined) {
_this.blocks[e.oldParent].next = null;
} else {
delete _this.blocks[e.oldParent].fields[e.oldField];
}
} else if (e.newParent !== undefined) {
// Block was moved to a new parent
// Either happens because it was previously parentless
// (e.oldParent === undefined)
// or because a block was moved in front of it.
// Remove stack
_this._deleteStack(e.id);
// Update new parent
if (e.newField === undefined) {
_this.blocks[e.newParent].next = e.id;
} else {
_this.blocks[e.newParent].fields[e.newField] = {
name: e.newField,
value: e.id,
blocks: {}
};
}
}
};
/**
* Block management: delete blocks and their associated stacks
* @param {!Object} e Blockly delete event to be processed
*/
Runtime.prototype.deleteBlock = function (e) {
// @todo Stop threads running on this stack
// Get block
var block = this.blocks[e.id];
// Delete children
if (block.next !== null) {
this.deleteBlock({id: block.next});
}
// Delete substacks and fields
for (var field in block.fields) {
if (field === 'SUBSTACK') {
this.deleteBlock({id: block.fields[field].value});
} else {
for (var shadow in block.fields[field].blocks) {
this.deleteBlock({id: shadow});
}
}
}
// Delete stack
this._deleteStack(e.id);
// Delete block
delete this.blocks[e.id];
};
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
/**
* Register default block packages with this runtime.
* @todo Prefix opcodes with package name.
* @private
*/
Runtime.prototype._registerBlockPackages = function () {
for (var packageName in defaultBlockPackages) {
if (defaultBlockPackages.hasOwnProperty(packageName)) {
// @todo pass a different runtime depending on package privilege?
var packageObject = new (defaultBlockPackages[packageName])(this);
var packageContents = packageObject.getPrimitives();
for (var op in packageContents) {
if (packageContents.hasOwnProperty(op)) {
this._primitives[op] =
packageContents[op].bind(packageObject);
}
}
}
}
};
/**
* Retrieve the function associated with the given opcode.
* @param {!string} opcode The opcode to look up.
* @return {Function} The function which implements the opcode.
*/
Runtime.prototype.getOpcodeFunction = function (opcode) {
return this._primitives[opcode];
};
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
/**
* Create a thread and push it to the list of threads.
* @param {!string} id ID of block that starts the stack
*/
Runtime.prototype._pushThread = function (id) {
this.emit(Runtime.STACK_GLOW_ON, id);
var thread = new Thread(id);
this.threads.push(thread);
};
/**
* Remove a thread from the list of threads.
* @param {?Thread} thread Thread object to remove from actives
*/
Runtime.prototype._removeThread = function (thread) {
var i = this.threads.indexOf(thread);
if (i > -1) {
this.emit(Runtime.STACK_GLOW_OFF, thread.topBlock);
this.threads.splice(i, 1);
}
};
/**
* Toggle a stack
* @param {!string} stackId ID of block that starts the stack
*/
Runtime.prototype.toggleStack = function (stackId) {
// Remove any existing thread
for (var i = 0; i < this.threads.length; i++) {
if (this.threads[i].topBlock == stackId) {
this._removeThread(this.threads[i]);
return;
}
}
// Otherwise add it
this._pushThread(stackId);
};
/**
* Green flag, which stops currently running threads
* and adds all top-level stacks that start with the green flag
*/
Runtime.prototype.greenFlag = function () {
// Remove all existing threads
for (var i = 0; i < this.threads.length; i++) {
this._removeThread(this.threads[i]);
}
// Add all top stacks with green flag
for (var j = 0; j < this.stacks.length; j++) {
var topBlock = this.stacks[j];
if (this.blocks[topBlock].opcode === 'event_whenflagclicked') {
this._pushThread(this.stacks[j]);
}
}
};
/**
* Distance sensor hack
*/
Runtime.prototype.startDistanceSensors = function () {
// Add all top stacks with distance sensor
for (var j = 0; j < this.stacks.length; j++) {
var topBlock = this.stacks[j];
if (this.blocks[topBlock].opcode === 'wedo_whendistanceclose') {
var alreadyRunning = false;
for (var k = 0; k < this.threads.length; k++) {
if (this.threads[k].topBlock === topBlock) {
alreadyRunning = true;
}
}
if (!alreadyRunning) {
this._pushThread(this.stacks[j]);
}
}
}
};
/**
* Stop "everything"
*/
Runtime.prototype.stopAll = function () {
var threadsCopy = this.threads.slice();
while (threadsCopy.length > 0) {
this._removeThread(threadsCopy.pop());
}
// @todo call stop function in all extensions/packages/WeDo stub
if (window.native) {
window.native.motorStop();
}
};
/**
* Repeatedly run `sequencer.stepThreads` and filter out
* inactive threads after each iteration.
*/
Runtime.prototype._step = function () {
var inactiveThreads = this.sequencer.stepThreads(this.threads);
for (var i = 0; i < inactiveThreads.length; i++) {
this._removeThread(inactiveThreads[i]);
}
};
/**
* Emit feedback for block glowing (used in the sequencer).
* @param {?string} blockId ID for the block to update glow
* @param {boolean} isGlowing True to turn on glow; false to turn off.
*/
Runtime.prototype.glowBlock = function (blockId, isGlowing) {
if (isGlowing) {
this.emit(Runtime.BLOCK_GLOW_ON, blockId);
} else {
this.emit(Runtime.BLOCK_GLOW_OFF, blockId);
}
};
/**
* Set up timers to repeatedly step in a browser
*/
Runtime.prototype.start = function () {
if (!window.setInterval) return;
window.setInterval(function() {
this._step();
}.bind(this), Runtime.THREAD_STEP_INTERVAL);
};
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
/**
* Helper to remove a stack from `this.stacks`
* @param {?string} id ID of block that starts the stack
*/
Runtime.prototype._deleteStack = function (id) {
var i = this.stacks.indexOf(id);
if (i > -1) this.stacks.splice(i, 1);
};
/**
* Helper to get the next block for a particular block
* @param {?string} id ID of block to get the next block for
* @return {?string} ID of next block in the sequence
*/
Runtime.prototype._getNextBlock = function (id) {
if (typeof this.blocks[id] === 'undefined') return null;
return this.blocks[id].next;
};
/**
* Helper to get the substack for a particular C-shaped block
* @param {?string} id ID for block to get the substack for
* @return {?string} ID of block in the substack
*/
Runtime.prototype._getSubstack = function (id) {
if (typeof this.blocks[id] === 'undefined') return null;
return this.blocks[id].fields['SUBSTACK'];
};
/**
* Helper to get the opcode for a particular block
* @param {?string} id ID of block to query
* @return {?string} the opcode corresponding to that block
*/
Runtime.prototype._getOpcode = function (id) {
if (typeof this.blocks[id] === 'undefined') return null;
return this.blocks[id].opcode;
};
module.exports = Runtime;

241
src/engine/sequencer.js Normal file
View file

@ -0,0 +1,241 @@
var Timer = require('../util/timer');
var Thread = require('./thread');
var YieldTimers = require('../util/yieldtimers.js');
function Sequencer (runtime) {
/**
* A utility timer for timing thread sequencing.
* @type {!Timer}
*/
this.timer = new Timer();
/**
* Reference to the runtime owning this sequencer.
* @type {!Runtime}
*/
this.runtime = runtime;
}
/**
* The sequencer does as much work as it can within WORK_TIME milliseconds,
* then yields. This is essentially a rate-limiter for blocks.
* In Scratch 2.0, this is set to 75% of the target stage frame-rate (30fps).
* @const {!number}
*/
Sequencer.WORK_TIME = 10;
/**
* Step through all threads in `this.threads`, running them in order.
* @return {Array.<Thread>} All threads which have finished in this iteration.
*/
Sequencer.prototype.stepThreads = function (threads) {
// Start counting toward WORK_TIME
this.timer.start();
// List of threads which have been killed by this step.
var inactiveThreads = [];
// If all of the threads are yielding, we should yield.
var numYieldingThreads = 0;
// While there are still threads to run and we are within WORK_TIME,
// continue executing threads.
while (threads.length > 0 &&
threads.length > numYieldingThreads &&
this.timer.timeElapsed() < Sequencer.WORK_TIME) {
// New threads at the end of the iteration.
var newThreads = [];
// Attempt to run each thread one time
for (var i = 0; i < threads.length; i++) {
var activeThread = threads[i];
if (activeThread.status === Thread.STATUS_RUNNING) {
// Normal-mode thread: step.
this.stepThread(activeThread);
} else if (activeThread.status === Thread.STATUS_YIELD) {
// Yield-mode thread: check if the time has passed.
YieldTimers.resolve(activeThread.yieldTimerId);
numYieldingThreads++;
} else if (activeThread.status === Thread.STATUS_DONE) {
// Moved to a done state - finish up
activeThread.status = Thread.STATUS_RUNNING;
// @todo Deal with the return value
}
// First attempt to pop from the stack
if (activeThread.stack.length > 0 &&
activeThread.nextBlock === null &&
activeThread.status === Thread.STATUS_DONE) {
activeThread.nextBlock = activeThread.stack.pop();
// Don't pop stack frame - we need the data.
// A new one won't be created when we execute.
if (activeThread.nextBlock !== null) {
activeThread.status === Thread.STATUS_RUNNING;
}
}
if (activeThread.nextBlock === null &&
activeThread.status === Thread.STATUS_DONE) {
// Finished with this thread - tell runtime to clean it up.
inactiveThreads.push(activeThread);
} else {
// Keep this thead in the loop.
newThreads.push(activeThread);
}
}
// Effectively filters out threads that have stopped.
threads = newThreads;
}
return inactiveThreads;
};
/**
* Step the requested thread
* @param {!Thread} thread Thread object to step
*/
Sequencer.prototype.stepThread = function (thread) {
// Save the yield timer ID, in case a primitive makes a new one
// @todo hack - perhaps patch this to allow more than one timer per
// primitive, for example...
var oldYieldTimerId = YieldTimers.timerId;
// Save the current block and set the nextBlock.
// If the primitive would like to do control flow,
// it can overwrite nextBlock.
var currentBlock = thread.nextBlock;
if (!currentBlock || !this.runtime.blocks[currentBlock]) {
thread.status = Thread.STATUS_DONE;
return;
}
thread.nextBlock = this.runtime._getNextBlock(currentBlock);
var opcode = this.runtime._getOpcode(currentBlock);
// Push the current block to the stack
thread.stack.push(currentBlock);
// Push an empty stack frame, if we need one.
// Might not, if we just popped the stack.
if (thread.stack.length > thread.stackFrames.length) {
thread.stackFrames.push({});
}
var currentStackFrame = thread.stackFrames[thread.stackFrames.length - 1];
/**
* A callback for the primitive to indicate its thread should yield.
* @type {Function}
*/
var threadYieldCallback = function () {
thread.status = Thread.STATUS_YIELD;
};
/**
* A callback for the primitive to indicate its thread is finished
* @type {Function}
*/
var instance = this;
var threadDoneCallback = function () {
thread.status = Thread.STATUS_DONE;
// Refresh nextBlock in case it has changed during a yield.
thread.nextBlock = instance.runtime._getNextBlock(currentBlock);
// Pop the stack and stack frame
thread.stack.pop();
thread.stackFrames.pop();
};
/**
* A callback for the primitive to start hats.
* @todo very hacked...
*/
var startHats = function(callback) {
for (var i = 0; i < instance.runtime.stacks.length; i++) {
var stack = instance.runtime.stacks[i];
var stackBlock = instance.runtime.blocks[stack];
var result = callback(stackBlock);
if (result) {
// Check if the stack is already running
var stackRunning = false;
for (var j = 0; j < instance.runtime.threads.length; j++) {
if (instance.runtime.threads[j].topBlock == stack) {
stackRunning = true;
break;
}
}
if (!stackRunning) {
instance.runtime._pushThread(stack);
}
}
}
};
/**
* Record whether we have switched stack,
* to avoid proceeding the thread automatically.
* @type {boolean}
*/
var switchedStack = false;
/**
* A callback for a primitive to start a substack.
* @type {Function}
*/
var threadStartSubstack = function () {
// Set nextBlock to the start of the substack
var substack = instance.runtime._getSubstack(currentBlock);
if (substack && substack.value) {
thread.nextBlock = substack.value;
} else {
thread.nextBlock = null;
}
switchedStack = true;
};
// @todo extreme hack to get the single argument value for prototype
var argValues = [];
var blockInputs = this.runtime.blocks[currentBlock].fields;
for (var bi in blockInputs) {
var outer = blockInputs[bi];
for (var b in outer.blocks) {
var block = outer.blocks[b];
var fields = block.fields;
for (var f in fields) {
var field = fields[f];
argValues.push(field.value);
}
}
}
if (!opcode) {
console.warn('Could not get opcode for block: ' + currentBlock);
}
else {
var blockFunction = this.runtime.getOpcodeFunction(opcode);
if (!blockFunction) {
console.warn('Could not get implementation for opcode: ' + opcode);
}
else {
try {
// @todo deal with the return value
blockFunction(argValues, {
yield: threadYieldCallback,
done: threadDoneCallback,
timeout: YieldTimers.timeout,
stackFrame: currentStackFrame,
startSubstack: threadStartSubstack,
startHats: startHats
});
}
catch(e) {
console.error(
'Exception calling block function for opcode: ' +
opcode + '\n' + e);
} finally {
// Update if the thread has set a yield timer ID
// @todo hack
if (YieldTimers.timerId > oldYieldTimerId) {
thread.yieldTimerId = YieldTimers.timerId;
}
if (thread.status === Thread.STATUS_RUNNING && !switchedStack) {
// Thread executed without yielding - move to done
threadDoneCallback();
}
}
}
}
};
module.exports = Sequencer;

65
src/engine/thread.js Normal file
View file

@ -0,0 +1,65 @@
/**
* A thread is a running stack context and all the metadata needed.
* @param {?string} firstBlock First block to execute in the thread.
* @constructor
*/
function Thread (firstBlock) {
/**
* ID of top block of the thread
* @type {!string}
*/
this.topBlock = firstBlock;
/**
* ID of next block that the thread will execute, or null if none.
* @type {?string}
*/
this.nextBlock = firstBlock;
/**
* Stack for the thread. When the sequencer enters a control structure,
* the block is pushed onto the stack so we know where to exit.
* @type {Array.<string>}
*/
this.stack = [];
/**
* Stack frames for the thread. Store metadata for the executing blocks.
* @type {Array.<Object>}
*/
this.stackFrames = [];
/**
* Status of the thread, one of three states (below)
* @type {number}
*/
this.status = 0; /* Thread.STATUS_RUNNING */
/**
* Yield timer ID (for checking when the thread should unyield).
* @type {number}
*/
this.yieldTimerId = -1;
}
/**
* Thread status for initialized or running thread.
* Threads are in this state when the primitive is called for the first time.
* @const
*/
Thread.STATUS_RUNNING = 0;
/**
* Thread status for a yielded thread.
* Threads are in this state when a primitive has yielded.
* @const
*/
Thread.STATUS_YIELD = 1;
/**
* Thread status for a finished/done thread.
* Thread is moved to this state when the interpreter
* can proceed with execution.
* @const
*/
Thread.STATUS_DONE = 2;
module.exports = Thread;

92
src/index.js Normal file
View file

@ -0,0 +1,92 @@
var EventEmitter = require('events');
var util = require('util');
var Runtime = require('./engine/runtime');
var adapter = require('./engine/adapter');
/**
* Handles connections between blocks, stage, and extensions.
*
* @author Andrew Sliwinski <ascii@media.mit.edu>
*/
function VirtualMachine () {
var instance = this;
// Bind event emitter and runtime to VM instance
// @todo Post message (Web Worker) polyfill
EventEmitter.call(instance);
instance.runtime = new Runtime();
/**
* Event listener for blocks. Handles validation and serves as a generic
* adapter between the blocks and the runtime interface.
*
* @param {Object} Blockly "block" event
*/
instance.blockListener = function (e) {
// Validate event
if (typeof e !== 'object') return;
if (typeof e.blockId !== 'string') return;
// Blocks
switch (e.type) {
case 'create':
instance.runtime.createBlock(adapter(e), false);
break;
case 'change':
instance.runtime.changeBlock({
id: e.blockId,
element: e.element,
name: e.name,
value: e.newValue
});
break;
case 'move':
instance.runtime.moveBlock({
id: e.blockId,
oldParent: e.oldParentId,
oldField: e.oldInputName,
newParent: e.newParentId,
newField: e.newInputName
});
break;
case 'delete':
instance.runtime.deleteBlock({
id: e.blockId
});
break;
}
};
instance.flyoutBlockListener = function (e) {
switch (e.type) {
case 'create':
instance.runtime.createBlock(adapter(e), true);
break;
case 'change':
instance.runtime.changeBlock({
id: e.blockId,
element: e.element,
name: e.name,
value: e.newValue
});
break;
case 'delete':
instance.runtime.deleteBlock({
id: e.blockId
});
break;
}
};
}
/**
* Inherit from EventEmitter
*/
util.inherits(VirtualMachine, EventEmitter);
/**
* Export and bind to `window`
*/
module.exports = VirtualMachine;
if (typeof window !== 'undefined') window.VirtualMachine = module.exports;

20
src/util/timer.js Normal file
View file

@ -0,0 +1,20 @@
/**
* Constructor
*/
function Timer () {
this.startTime = 0;
}
Timer.prototype.time = function () {
return Date.now();
};
Timer.prototype.start = function () {
this.startTime = this.time();
};
Timer.prototype.timeElapsed = function () {
return this.time() - this.startTime;
};
module.exports = Timer;

90
src/util/yieldtimers.js Normal file
View file

@ -0,0 +1,90 @@
/**
* @fileoverview Timers that are synchronized with the Scratch sequencer.
*/
var Timer = require('./timer');
function YieldTimers () {}
/**
* Shared collection of timers.
* Each timer is a [Function, number] with the callback
* and absolute time for it to run.
* @type {Object.<number,Array>}
*/
YieldTimers.timers = {};
/**
* Monotonically increasing timer ID.
* @type {number}
*/
YieldTimers.timerId = 0;
/**
* Utility for measuring time.
* @type {!Timer}
*/
YieldTimers.globalTimer = new Timer();
/**
* The timeout function is passed to primitives and is intended
* as a convenient replacement for window.setTimeout.
* The sequencer will attempt to resolve the timer every time
* the yielded thread would have been stepped.
* @param {!Function} callback To be called when the timer is done.
* @param {number} timeDelta Time to wait, in ms.
* @return {number} Timer ID to be used with other methods.
*/
YieldTimers.timeout = function (callback, timeDelta) {
var id = ++YieldTimers.timerId;
YieldTimers.timers[id] = [
callback,
YieldTimers.globalTimer.time() + timeDelta
];
return id;
};
/**
* Attempt to resolve a timeout.
* If the time has passed, call the callback.
* Otherwise, do nothing.
* @param {number} id Timer ID to resolve.
* @return {boolean} True if the timer has resolved.
*/
YieldTimers.resolve = function (id) {
var timer = YieldTimers.timers[id];
if (!timer) {
// No such timer.
return false;
}
var callback = timer[0];
var time = timer[1];
if (YieldTimers.globalTimer.time() < time) {
// Not done yet.
return false;
}
// Execute the callback and remove the timer.
callback();
delete YieldTimers.timers[id];
return true;
};
/**
* Reject a timer so the callback never executes.
* @param {number} id Timer ID to reject.
*/
YieldTimers.reject = function (id) {
if (YieldTimers.timers[id]) {
delete YieldTimers.timers[id];
}
};
/**
* Reject all timers currently stored.
* Especially useful for a Scratch "stop."
*/
YieldTimers.rejectAll = function () {
YieldTimers.timers = {};
YieldTimers.timerId = 0;
};
module.exports = YieldTimers;

20
test/fixtures/events.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
"create": {
"blockId": "z!+#Nqr,_(V=xz0y7a@d",
"workspaceId": "7Luws3lyb*Z98~Kk+IG|",
"group": ";OswyM#@%`%,xOrhOXC=",
"recordUndo": true,
"xml": {
"attributes": {
"type": {
"value": "wedo_motorclockwise"
}
},
"innerHTML": "<value name=\"DURATION\"><shadow type=\"math_number\" id=\"!6Ahqg4f}Ljl}X5Hws?Z\"><field name=\"NUM\">10</field></shadow></value>"
},
"ids": [
"z!+#Nqr,_(V=xz0y7a@d",
"!6Ahqg4f}Ljl}X5Hws?Z"
]
}
}

27
test/integration/index.js Normal file
View file

@ -0,0 +1,27 @@
var test = require('tap').test;
var VirtualMachine = require('../../src/index');
test('spec', function (t) {
var vm = new VirtualMachine();
t.type(VirtualMachine, 'function');
t.type(vm, 'object');
t.type(vm.blockListener, 'function');
t.end();
});
test('create', function (t) {
t.end();
});
test('move', function (t) {
t.end();
});
test('change', function (t) {
t.end();
});
test('delete', function (t) {
t.end();
});

20
test/unit/adapter.js Normal file
View file

@ -0,0 +1,20 @@
var test = require('tap').test;
var adapter = require('../../src/engine/adapter');
var events = require('../fixtures/events.json');
test('spec', function (t) {
t.type(adapter, 'function');
t.end();
});
test('create event', function (t) {
var result = adapter(events.create);
t.type(result, 'object');
t.type(result.id, 'string');
t.type(result.opcode, 'string');
t.type(result.fields, 'object');
t.type(result.fields['DURATION'], 'object');
t.end();
});

89
test/unit/runtime.js Normal file
View file

@ -0,0 +1,89 @@
var test = require('tap').test;
var Runtime = require('../../src/engine/runtime');
test('spec', function (t) {
var r = new Runtime();
t.type(Runtime, 'function');
t.type(r, 'object');
t.ok(r instanceof Runtime);
t.type(r.blocks, 'object');
t.type(r.stacks, 'object');
t.ok(Array.isArray(r.stacks));
t.type(r.createBlock, 'function');
t.type(r.moveBlock, 'function');
t.type(r.changeBlock, 'function');
t.type(r.deleteBlock, 'function');
t.end();
});
test('create', function (t) {
var r = new Runtime();
r.createBlock({
id: 'foo',
opcode: 'TEST_BLOCK',
next: null,
fields: {}
});
t.type(r.blocks['foo'], 'object');
t.equal(r.blocks['foo'].opcode, 'TEST_BLOCK');
t.notEqual(r.stacks.indexOf('foo'), -1);
t.end();
});
test('move', function (t) {
var r = new Runtime();
r.createBlock({
id: 'foo',
opcode: 'TEST_BLOCK',
next: null,
fields: {}
});
r.createBlock({
id: 'bar',
opcode: 'TEST_BLOCK',
next: null,
fields: {}
});
// Attach 'bar' to the end of 'foo'
r.moveBlock({
id: 'bar',
newParent: 'foo'
});
t.equal(r.stacks.length, 1);
t.equal(Object.keys(r.blocks).length, 2);
t.equal(r.blocks['foo'].next, 'bar');
// Detach 'bar' from 'foo'
r.moveBlock({
id: 'bar',
oldParent: 'foo'
});
t.equal(r.stacks.length, 2);
t.equal(Object.keys(r.blocks).length, 2);
t.equal(r.blocks['foo'].next, null);
t.end();
});
test('delete', function (t) {
var r = new Runtime();
r.createBlock({
id: 'foo',
opcode: 'TEST_BLOCK',
next: null,
fields: {}
});
r.deleteBlock({
id: 'foo'
});
t.type(r.blocks['foo'], 'undefined');
t.equal(r.stacks.indexOf('foo'), -1);
t.end();
});

8
test/unit/sequencer.js Normal file
View file

@ -0,0 +1,8 @@
var test = require('tap').test;
var Sequencer = require('../../src/engine/sequencer');
test('spec', function (t) {
t.type(Sequencer, 'function');
// @todo
t.end();
});

View file

@ -1,6 +0,0 @@
var test = require('tap').test;
var vm = require('../../index');
test('spec', function (t) {
t.end();
});

8
test/unit/thread.js Normal file
View file

@ -0,0 +1,8 @@
var test = require('tap').test;
var Thread = require('../../src/engine/thread');
test('spec', function (t) {
t.type(Thread, 'function');
// @todo
t.end();
});

42
test/unit/timer.js Normal file
View file

@ -0,0 +1,42 @@
var test = require('tap').test;
var Timer = require('../../src/util/timer');
test('spec', function (t) {
var timer = new Timer();
t.type(Timer, 'function');
t.type(timer, 'object');
t.type(timer.startTime, 'number');
t.type(timer.time, 'function');
t.type(timer.start, 'function');
t.type(timer.timeElapsed, 'function');
t.end();
});
test('time', function (t) {
var timer = new Timer();
var time = timer.time();
t.ok(Date.now() >= time);
t.end();
});
test('start / timeElapsed', function (t) {
var timer = new Timer();
var delay = 100;
var threshold = 1000 / 60; // 60 hz
// Start timer
timer.start();
// Wait and measure timer
setTimeout(function () {
var timeElapsed = timer.timeElapsed();
t.ok(timeElapsed >= 0);
t.ok(timeElapsed >= (delay - threshold) &&
timeElapsed <= (delay + threshold));
t.end();
}, delay);
});

13805
vm.js Normal file

File diff suppressed because it is too large Load diff

11
vm.min.js vendored Normal file

File diff suppressed because one or more lines are too long

29
webpack.config.js Normal file
View file

@ -0,0 +1,29 @@
var webpack = require('webpack');
module.exports = {
entry: {
'vm': './src/index.js',
'vm.min': './src/index.js'
},
output: {
path: __dirname,
filename: '[name].js'
},
module: {
loaders: [
{
test: /\.json$/,
loader: 'json-loader'
}
]
},
plugins: [
new webpack.optimize.UglifyJsPlugin({
include: /\.min\.js$/,
minimize: true,
compress: {
warnings: false
}
})
]
};