diff --git a/src/blocks/scratch3_pen.js b/src/blocks/scratch3_pen.js new file mode 100644 index 000000000..5d652531b --- /dev/null +++ b/src/blocks/scratch3_pen.js @@ -0,0 +1,333 @@ +var Cast = require('../util/cast'); +var Clone = require('../util/clone'); +var Color = require('../util/color'); +var MathUtil = require('../util/math-util'); +var RenderedTarget = require('../sprites/rendered-target'); + +/** + * @typedef {object} PenState - the pen state associated with a particular target. + * @property {Boolean} penDown - tracks whether the pen should draw for this target. + * @property {number} hue - the current hue of the pen. + * @property {number} shade - the current shade of the pen. + * @property {PenAttributes} penAttributes - cached pen attributes for the renderer. This is the authoritative value for + * diameter but not for pen color. + */ + +/** + * Host for the Pen-related blocks in Scratch 3.0 + * @param {Runtime} runtime - the runtime instantiating this block package. + * @constructor + */ +var Scratch3PenBlocks = function (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + /** + * The ID of the renderer Drawable corresponding to the pen layer. + * @type {int} + * @private + */ + this._penDrawableId = -1; + + /** + * The ID of the renderer Skin corresponding to the pen layer. + * @type {int} + * @private + */ + this._penSkinId = -1; + + this._onTargetMoved = this._onTargetMoved.bind(this); +}; + +/** + * The default pen state, to be used when a target has no existing pen state. + * @type {PenState} + */ +Scratch3PenBlocks.DEFAULT_PEN_STATE = { + penDown: false, + hue: 120, + shade: 50, + penAttributes: { + color4f: [0, 0, 1, 1], + diameter: 1 + } +}; + +/** + * Place the pen layer in front of the backdrop but behind everything else. + * We should probably handle this somewhere else... somewhere central that knows about pen, backdrop, video, etc. + * Maybe it should be in the GUI? + * @type {int} + */ +Scratch3PenBlocks.PEN_ORDER = 1; + +/** + * The minimum and maximum allowed pen size. + * @type {{min: number, max: number}} + */ +Scratch3PenBlocks.PEN_SIZE_RANGE = {min: 1, max: 255}; + +/** + * The key to load & store a target's pen-related state. + * @type {string} + */ +Scratch3PenBlocks.STATE_KEY = 'Scratch.pen'; + +/** + * Clamp a pen size value to the range allowed by the pen. + * @param {number} requestedSize - the requested pen size. + * @returns {number} the clamped size. + * @private + */ +Scratch3PenBlocks.prototype._clampPenSize = function (requestedSize) { + return MathUtil.clamp(requestedSize, Scratch3PenBlocks.PEN_SIZE_RANGE.min, Scratch3PenBlocks.PEN_SIZE_RANGE.max); +}; + +/** + * Retrieve the ID of the renderer "Skin" corresponding to the pen layer. If the pen Skin doesn't yet exist, create it. + * @returns {int} the Skin ID of the pen layer, or -1 on failure. + * @private + */ +Scratch3PenBlocks.prototype._getPenLayerID = function () { + if (this._penSkinId < 0 && this.runtime.renderer) { + this._penSkinId = this.runtime.renderer.createPenSkin(); + this._penDrawableId = this.runtime.renderer.createDrawable(); + this.runtime.renderer.setDrawableOrder(this._penDrawableId, Scratch3PenBlocks.PEN_ORDER); + this.runtime.renderer.updateDrawableProperties(this._penDrawableId, {skinId: this._penSkinId}); + } + return this._penSkinId; +}; + +/** + * @param {Target} target - collect pen state for this target. Probably, but not necessarily, a RenderedTarget. + * @returns {PenState} the mutable pen state associated with that target. This will be created if necessary. + * @private + */ +Scratch3PenBlocks.prototype._getPenState = function (target) { + var penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY); + if (!penState) { + penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE); + target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState); + } + return penState; +}; + +/** + * Handle a target which has moved. This only fires when the pen is down. + * @param {RenderedTarget} target - the target which has moved. + * @param {number} oldX - the previous X position. + * @param {number} oldY - the previous Y position. + * @private + */ +Scratch3PenBlocks.prototype._onTargetMoved = function (target, oldX, oldY) { + var penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + var penState = this._getPenState(target); + this.runtime.renderer.penLine(penSkinId, penState.penAttributes, oldX, oldY, target.x, target.y); + this.runtime.requestRedraw(); + } +}; + +/** + * Update the cached RGB color from the hue & shade values in the provided PenState object. + * @param {PenState} penState - the pen state to update. + * @private + */ +Scratch3PenBlocks.prototype._updatePenColor = function (penState) { + var rgb = Color.hsvToRgb({h: penState.hue * 180 / 100, s: 1, v: 1}); + var shade = (penState.shade > 100) ? 200 - penState.shade : penState.shade; + if (shade < 50) { + rgb = Color.mixRgb(Color.RGB_BLACK, rgb, (10 + shade) / 60); + } else { + rgb = Color.mixRgb(rgb, Color.RGB_WHITE, (shade - 50) / 60); + } + penState.penAttributes.color4f[0] = rgb.r / 255.0; + penState.penAttributes.color4f[1] = rgb.g / 255.0; + penState.penAttributes.color4f[2] = rgb.b / 255.0; +}; + +/** + * Wrap a pen hue or shade values to the range [0,200). + * @param {number} value - the pen hue or shade value to the proper range. + * @returns {number} the wrapped value. + * @private + */ +Scratch3PenBlocks.prototype._wrapHueOrShade = function (value) { + value = value % 200; + if (value < 0) value += 200; + return value; +}; + +/** + * Retrieve the block primitives implemented by this package. + * @return {Object.<string, Function>} Mapping of opcode to Function. + */ +Scratch3PenBlocks.prototype.getPrimitives = function () { + return { + pen_clear: this.clear, + pen_stamp: this.stamp, + pen_pendown: this.penDown, + pen_penup: this.penUp, + pen_setpencolortocolor: this.setPenColorToColor, + pen_changepencolorby: this.changePenHueBy, + pen_setpencolortonum: this.setPenHueToNumber, + pen_changepenshadeby: this.changePenShadeBy, + pen_setpenshadeto: this.setPenShadeToNumber, + pen_changepensizeby: this.changePenSizeBy, + pen_setpensizeto: this.setPenSizeTo + }; +}; + +/** + * The pen "clear" block clears the pen layer's contents. + */ +Scratch3PenBlocks.prototype.clear = function () { + var penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + this.runtime.renderer.penClear(penSkinId); + this.runtime.requestRedraw(); + } +}; + +/** + * The pen "stamp" block stamps the current drawable's image onto the pen layer. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.stamp = function (args, util) { + var penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + var target = util.target; + this.runtime.renderer.penStamp(penSkinId, target.drawableID); + this.runtime.requestRedraw(); + } +}; + +/** + * The pen "pen down" block causes the target to leave pen trails on future motion. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.penDown = function (args, util) { + var target = util.target; + var penState = this._getPenState(target); + + if (!penState.penDown) { + penState.penDown = true; + target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } + + var penSkinId = this._getPenLayerID(); + if (penSkinId >= 0) { + this.runtime.renderer.penPoint(penSkinId, penState.penAttributes, target.x, target.y); + this.runtime.requestRedraw(); + } +}; + +/** + * The pen "pen up" block stops the target from leaving pen trails. + * @param {object} args - the block arguments. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.penUp = function (args, util) { + var target = util.target; + var penState = this._getPenState(target); + + if (penState.penDown) { + penState.penDown = false; + target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + } +}; + +/** + * The pen "set pen color to {color}" block sets the pen to a particular RGB color. + * @param {object} args - the block arguments. + * @property {int} COLOR - the color to set, expressed as a 24-bit RGB value (0xRRGGBB). + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.setPenColorToColor = function (args, util) { + var penState = this._getPenState(util.target); + var rgb = Cast.toRgbColorObject(args.COLOR); + var hsv = Color.rgbToHsv(rgb); + + penState.hue = 200 * hsv.h / 360; + penState.shade = 50 * hsv.v; + penState.penAttributes.color4f[0] = rgb.r / 255.0; + penState.penAttributes.color4f[1] = rgb.g / 255.0; + penState.penAttributes.color4f[2] = rgb.b / 255.0; +}; + +/** + * The pen "change pen color by {number}" block rotates the hue of the pen by the given amount. + * @param {object} args - the block arguments. + * @property {number} COLOR - the amount of desired hue rotation. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.changePenHueBy = function (args, util) { + var penState = this._getPenState(util.target); + penState.hue = this._wrapHueOrShade(penState.hue + Cast.toNumber(args.COLOR)); + this._updatePenColor(penState); +}; + +/** + * The pen "set pen color to {number}" block sets the hue of the pen. + * @param {object} args - the block arguments. + * @property {number} COLOR - the desired hue. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.setPenHueToNumber = function (args, util) { + var penState = this._getPenState(util.target); + penState.hue = this._wrapHueOrShade(Cast.toNumber(args.COLOR)); + this._updatePenColor(penState); +}; + +/** + * The pen "change pen shade by {number}" block changes the "shade" of the pen, related to the HSV value. + * @param {object} args - the block arguments. + * @property {number} SHADE - the amount of desired shade change. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.changePenShadeBy = function (args, util) { + var penState = this._getPenState(util.target); + penState.shade = this._wrapHueOrShade(penState.shade + Cast.toNumber(args.SHADE)); + this._updatePenColor(penState); +}; + +/** + * The pen "set pen shade to {number}" block sets the "shade" of the pen, related to the HSV value. + * @param {object} args - the block arguments. + * @property {number} SHADE - the amount of desired shade change. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.setPenShadeToNumber = function (args, util) { + var penState = this._getPenState(util.target); + penState.shade = this._wrapHueOrShade(Cast.toNumber(args.SHADE)); + this._updatePenColor(penState); +}; + +/** + * The pen "change pen size by {number}" block changes the pen size by the given amount. + * @param {object} args - the block arguments. + * @property {number} SIZE - the amount of desired size change. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.changePenSizeBy = function (args, util) { + var penAttributes = this._getPenState(util.target).penAttributes; + penAttributes.diameter = this._clampPenSize(penAttributes.diameter + Cast.toNumber(args.SIZE)); +}; + +/** + * The pen "set pen size to {number}" block sets the pen size to the given amount. + * @param {object} args - the block arguments. + * @property {number} SIZE - the amount of desired size change. + * @param {object} util - utility object provided by the runtime. + */ +Scratch3PenBlocks.prototype.setPenSizeTo = function (args, util) { + var penAttributes = this._getPenState(util.target).penAttributes; + penAttributes.diameter = this._clampPenSize(Cast.toNumber(args.SIZE)); +}; + +module.exports = Scratch3PenBlocks; diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 80b86d926..3a1967baa 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -15,6 +15,7 @@ var defaultBlockPackages = { scratch3_looks: require('../blocks/scratch3_looks'), scratch3_motion: require('../blocks/scratch3_motion'), scratch3_operators: require('../blocks/scratch3_operators'), + scratch3_pen: require('../blocks/scratch3_pen'), scratch3_sound: require('../blocks/scratch3_sound'), scratch3_sensing: require('../blocks/scratch3_sensing'), scratch3_data: require('../blocks/scratch3_data'), diff --git a/src/engine/target.js b/src/engine/target.js index e36790b9b..947df53d3 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -1,3 +1,6 @@ +var EventEmitter = require('events'); +var util = require('util'); + var Blocks = require('./blocks'); var Variable = require('../engine/variable'); var List = require('../engine/list'); @@ -14,6 +17,8 @@ var uid = require('../util/uid'); * @constructor */ var Target = function (blocks) { + EventEmitter.call(this); + if (!blocks) { blocks = new Blocks(this); } @@ -39,8 +44,20 @@ var Target = function (blocks) { * @type {Object.<string,*>} */ this.lists = {}; + /** + * Dictionary of custom state for this target. + * This can be used to store target-specific custom state for blocks which need it. + * TODO: do we want to persist this in SB3 files? + * @type {Object.<string,*>} + */ + this._customState = {}; }; +/** + * Inherit from EventEmitter + */ +util.inherits(Target, EventEmitter); + /** * Called when the project receives a "green flag." * @abstract @@ -112,10 +129,30 @@ Target.prototype.lookupOrCreateList = function (name) { */ Target.prototype.postSpriteInfo = function () {}; +/** + * Retrieve custom state associated with this target and the provided state ID. + * @param {string} stateId - specify which piece of state to retrieve. + * @returns {*} the associated state, if any was found. + */ +Target.prototype.getCustomState = function (stateId) { + return this._customState[stateId]; +}; + +/** + * Store custom state associated with this target and the provided state ID. + * @param {string} stateId - specify which piece of state to store on this target. + * @param {*} newValue - the state value to store. + */ +Target.prototype.setCustomState = function (stateId, newValue) { + this._customState[stateId] = newValue; +}; + /** * Call to destroy a target. * @abstract */ -Target.prototype.dispose = function () {}; +Target.prototype.dispose = function () { + this._customState = {}; +}; module.exports = Target; diff --git a/src/import/sb2specmap.js b/src/import/sb2specmap.js index 204f3c543..95aad5019 100644 --- a/src/import/sb2specmap.js +++ b/src/import/sb2specmap.js @@ -563,7 +563,7 @@ var specMap = { ] }, 'setPenShadeTo:': { - opcode: 'pen_changepenshadeby', + opcode: 'pen_setpenshadeto', argMap: [ { type: 'input', diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index dc7c25d2f..36affd7b1 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -125,6 +125,12 @@ RenderedTarget.prototype.size = 100; */ RenderedTarget.prototype.currentCostume = 0; +/** + * Event which fires when a target moves. + * @type {string} + */ +RenderedTarget.EVENT_TARGET_MOVED = 'TARGET_MOVED'; + /** * Rotation style for "all around"/spinning. * @enum @@ -160,6 +166,8 @@ RenderedTarget.prototype.setXY = function (x, y) { if (this.isStage) { return; } + var oldX = this.x; + var oldY = this.y; this.x = x; this.y = y; if (this.renderer) { @@ -170,6 +178,7 @@ RenderedTarget.prototype.setXY = function (x, y) { this.runtime.requestRedraw(); } } + this.emit(RenderedTarget.EVENT_TARGET_MOVED, this, oldX, oldY); this.runtime.spriteInfoReport(this); }; diff --git a/src/util/cast.js b/src/util/cast.js index f14e97df5..0723b5ecd 100644 --- a/src/util/cast.js +++ b/src/util/cast.js @@ -66,18 +66,28 @@ Cast.toString = function (value) { }; /** - * Cast any Scratch argument to an RGB color object to be used for the renderer. - * @param {*} value Value to convert to RGB color object. + * Cast any Scratch argument to an RGB color array to be used for the renderer. + * @param {*} value Value to convert to RGB color array. * @return {Array.<number>} [r,g,b], values between 0-255. */ Cast.toRgbColorList = function (value) { + var color = Cast.toRgbColorObject(value); + return [color.r, color.g, color.b]; +}; + +/** + * Cast any Scratch argument to an RGB color object to be used for the renderer. + * @param {*} value Value to convert to RGB color object. + * @return {RGBOject} [r,g,b], values between 0-255. + */ +Cast.toRgbColorObject = function (value) { var color; if (typeof value === 'string' && value.substring(0, 1) === '#') { color = Color.hexToRgb(value); } else { color = Color.decimalToRgb(Cast.toNumber(value)); } - return [color.r, color.g, color.b]; + return color; }; /** diff --git a/src/util/clone.js b/src/util/clone.js new file mode 100644 index 000000000..5e1011a29 --- /dev/null +++ b/src/util/clone.js @@ -0,0 +1,17 @@ +/** + * Methods for cloning JavaScript objects. + * @type {object} + */ +var Clone = {}; + +/** + * Deep-clone a "simple" object: one which can be fully expressed with JSON. + * Non-JSON values, such as functions, will be stripped from the clone. + * @param {object} original - the object to be cloned. + * @returns {object} a deep clone of the original object. + */ +Clone.simple = function (original) { + return JSON.parse(JSON.stringify(original)); +}; + +module.exports = Clone; diff --git a/src/util/color.js b/src/util/color.js index 3e5054652..951cb270a 100644 --- a/src/util/color.js +++ b/src/util/color.js @@ -1,5 +1,25 @@ var Color = function () {}; +/** + * @typedef {object} RGBObject - An object representing a color in RGB format. + * @property {number} r - the red component, in the range [0, 255]. + * @property {number} g - the green component, in the range [0, 255]. + * @property {number} b - the blue component, in the range [0, 255]. + */ + +/** + * @typedef {object} HSVObject - An object representing a color in HSV format. + * @property {number} h - hue, in the range [0-359). + * @property {number} s - saturation, in the range [0,1]. + * @property {number} v - value, in the range [0,1]. + */ + +/** @type {RGBObject} */ +Color.RGB_BLACK = {r: 0, g: 0, b: 0}; + +/** @type {RGBObject} */ +Color.RGB_WHITE = {r: 255, g: 255, b: 255}; + /** * Convert a Scratch decimal color to a hex string, #RRGGBB. * @param {number} decimal RGB color as a decimal. @@ -17,7 +37,7 @@ Color.decimalToHex = function (decimal) { /** * Convert a Scratch decimal color to an RGB color object. * @param {number} decimal RGB color as decimal. - * @returns {Object} {r: R, g: G, b: B}, values between 0-255 + * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. */ Color.decimalToRgb = function (decimal) { var r = (decimal >> 16) & 0xFF; @@ -31,7 +51,7 @@ Color.decimalToRgb = function (decimal) { * CC-BY-SA Tim Down: * https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb * @param {!string} hex Hex representation of the color. - * @return {Object} {r: R, g: G, b: B}, 0-255, or null. + * @return {RGBObject} null on failure, or rgb: {r: red [0,255], g: green [0,255], b: blue [0,255]}. */ Color.hexToRgb = function (hex) { var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; @@ -48,7 +68,7 @@ Color.hexToRgb = function (hex) { /** * Convert an RGB color object to a hex color. - * @param {Object} rgb {r: R, g: G, b: B}, values between 0-255. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. * @return {!string} Hex representation of the color. */ Color.rgbToHex = function (rgb) { @@ -57,7 +77,7 @@ Color.rgbToHex = function (rgb) { /** * Convert an RGB color object to a Scratch decimal color. - * @param {Object} rgb {r: R, g: G, b: B}, values between 0-255. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. * @return {!number} Number representing the color. */ Color.rgbToDecimal = function (rgb) { @@ -73,4 +93,109 @@ Color.hexToDecimal = function (hex) { return Color.rgbToDecimal(Color.hexToRgb(hex)); }; +/** + * Convert an HSV color to RGB format. + * @param {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} + * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + */ +Color.hsvToRgb = function (hsv) { + var h = hsv.h % 360; + if (h < 0) h += 360; + var s = Math.max(0, Math.min(hsv.s, 1)); + var v = Math.max(0, Math.min(hsv.v, 1)); + + var i = Math.floor(h / 60); + var f = (h / 60) - i; + var p = v * (1 - s); + var q = v * (1 - (s * f)); + var t = v * (1 - (s * (1 - f))); + + var r; + var g; + var b; + + switch (i) { + default: + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + case 5: + r = v; + g = p; + b = q; + break; + } + + return { + r: Math.floor(r * 255), + g: Math.floor(g * 255), + b: Math.floor(b * 255) + }; +}; + +/** + * Convert an RGB color to HSV format. + * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. + * @return {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} + */ +Color.rgbToHsv = function (rgb) { + var r = rgb.r / 255; + var g = rgb.g / 255; + var b = rgb.b / 255; + var x = Math.min(Math.min(r, g), b); + var v = Math.max(Math.max(r, g), b); + + // For grays, hue will be arbitrarily reported as zero. Otherwise, calculate + var h = 0; + var s = 0; + if (x !== v) { + var f = (r === x) ? g - b : ((g === x) ? b - r : r - g); + var i = (r === x) ? 3 : ((g === x) ? 5 : 1); + h = ((i - (f / (v - x))) * 60) % 360; + s = (v - x) / v; + } + + return {h: h, s: s, v: v}; +}; + +/** + * Linear interpolation between rgb0 and rgb1. + * @param {RGBObject} rgb0 - the color corresponding to fraction1 <= 0. + * @param {RGBObject} rgb1 - the color corresponding to fraction1 >= 1. + * @param {number} fraction1 - the interpolation parameter. If this is 0.5, for example, mix the two colors equally. + * @return {RGBObject} the interpolated color. + */ +Color.mixRgb = function (rgb0, rgb1, fraction1) { + if (fraction1 <= 0) return rgb0; + if (fraction1 >= 1) return rgb1; + var fraction0 = 1 - fraction1; + return { + r: (fraction0 * rgb0.r) + (fraction1 * rgb1.r), + g: (fraction0 * rgb0.g) + (fraction1 * rgb1.g), + b: (fraction0 * rgb0.b) + (fraction1 * rgb1.b) + }; +}; + module.exports = Color; diff --git a/test/fixtures/pen.sb2 b/test/fixtures/pen.sb2 new file mode 100644 index 000000000..5d68aa0c5 Binary files /dev/null and b/test/fixtures/pen.sb2 differ diff --git a/test/integration/pen.js b/test/integration/pen.js new file mode 100644 index 000000000..8256e13cb --- /dev/null +++ b/test/integration/pen.js @@ -0,0 +1,34 @@ +var path = require('path'); +var test = require('tap').test; +var extract = require('../fixtures/extract'); +var VirtualMachine = require('../../src/index'); + +var uri = path.resolve(__dirname, '../fixtures/pen.sb2'); +var project = extract(uri); + +test('pen', function (t) { + var vm = new VirtualMachine(); + + // Evaluate playground data and exit + vm.on('playgroundData', function () { + // @todo Additional tests + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(function () { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project); + vm.greenFlag(); + }); + + // After two seconds, get playground data and stop + setTimeout(function () { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); +}); diff --git a/test/unit/util_cast.js b/test/unit/util_cast.js index 147ba2575..4f15d16bb 100644 --- a/test/unit/util_cast.js +++ b/test/unit/util_cast.js @@ -91,6 +91,24 @@ test('toRbgColorList', function (t) { t.end(); }); +test('toRbgColorObject', function (t) { + // Hex (minimal, see "color" util tests) + t.deepEqual(cast.toRgbColorObject('#000'), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('#000000'), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('#fff'), {r: 255, g: 255, b: 255}); + t.deepEqual(cast.toRgbColorObject('#ffffff'), {r: 255, g: 255, b: 255}); + + // Decimal (minimal, see "color" util tests) + t.deepEqual(cast.toRgbColorObject(0), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject(1), {r: 0, g: 0, b: 1}); + t.deepEqual(cast.toRgbColorObject(16777215), {r: 255, g: 255, b: 255}); + + // Malformed + t.deepEqual(cast.toRgbColorObject('ffffff'), {r: 0, g: 0, b: 0}); + t.deepEqual(cast.toRgbColorObject('foobar'), {r: 0, g: 0, b: 0}); + t.end(); +}); + test('compare', function (t) { // Numeric t.strictEqual(cast.compare(0, 0), 0); diff --git a/test/unit/util_color.js b/test/unit/util_color.js index c0db8ee90..1ffb737eb 100644 --- a/test/unit/util_color.js +++ b/test/unit/util_color.js @@ -1,6 +1,42 @@ var test = require('tap').test; var color = require('../../src/util/color'); +/** + * Assert that two HSV colors are similar to each other, within a tolerance. + * @param {Test} t - the Tap test object. + * @param {HSVObject} actual - the first HSV color to compare. + * @param {HSVObject} expected - the other HSV color to compare. + */ +var hsvSimilar = function (t, actual, expected) { + if ((Math.abs(actual.h - expected.h) >= 1) || + (Math.abs(actual.s - expected.s) >= 0.01) || + (Math.abs(actual.v - expected.v) >= 0.01) + ) { + t.fail('HSV colors not similar enough', { + actual: actual, + expected: expected + }); + } +}; + +/** + * Assert that two RGB colors are similar to each other, within a tolerance. + * @param {Test} t - the Tap test object. + * @param {RGBObject} actual - the first RGB color to compare. + * @param {RGBObject} expected - the other RGB color to compare. + */ +var rgbSimilar = function (t, actual, expected) { + if ((Math.abs(actual.r - expected.r) >= 1) || + (Math.abs(actual.g - expected.g) >= 1) || + (Math.abs(actual.b - expected.b) >= 1) + ) { + t.fail('RGB colors not similar enough', { + actual: actual, + expected: expected + }); + } +}; + test('decimalToHex', function (t) { t.strictEqual(color.decimalToHex(0), '#000000'); t.strictEqual(color.decimalToHex(1), '#000001'); @@ -60,3 +96,37 @@ test('hexToDecimal', function (t) { t.strictEqual(color.hexToDecimal('#00ffaa'), 65450); t.end(); }); + +test('hsvToRgb', function (t) { + rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 0}), {r: 0, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 123, s: 0.1234, v: 0}), {r: 0, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 1}), {r: 255, g: 255, b: 255}); + rgbSimilar(t, color.hsvToRgb({h: 321, s: 0, v: 1}), {r: 255, g: 255, b: 255}); + rgbSimilar(t, color.hsvToRgb({h: 0, s: 1, v: 1}), {r: 255, g: 0, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 120, s: 1, v: 1}), {r: 0, g: 255, b: 0}); + rgbSimilar(t, color.hsvToRgb({h: 240, s: 1, v: 1}), {r: 0, g: 0, b: 255}); + t.end(); +}); + +test('rgbToHsv', function (t) { + hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 0}), {h: 0, s: 0, v: 0}); + hsvSimilar(t, color.rgbToHsv({r: 64, g: 64, b: 64}), {h: 0, s: 0, v: 0.25}); + hsvSimilar(t, color.rgbToHsv({r: 128, g: 128, b: 128}), {h: 0, s: 0, v: 0.5}); + hsvSimilar(t, color.rgbToHsv({r: 192, g: 192, b: 192}), {h: 0, s: 0, v: 0.75}); + hsvSimilar(t, color.rgbToHsv({r: 255, g: 255, b: 255}), {h: 0, s: 0, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 255, g: 0, b: 0}), {h: 0, s: 1, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 0, g: 255, b: 0}), {h: 120, s: 1, v: 1}); + hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 255}), {h: 240, s: 1, v: 1}); + t.end(); +}); + +test('mixRgb', function (t) { + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, -1), {r: 10, g: 20, b: 30}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0), {r: 10, g: 20, b: 30}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.25), {r: 15, g: 25, b: 35}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.5), {r: 20, g: 30, b: 40}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.75), {r: 25, g: 35, b: 45}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 1), {r: 30, g: 40, b: 50}); + rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 2), {r: 30, g: 40, b: 50}); + t.end(); +});