const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const Cast = require('../../util/cast'); const Clone = require('../../util/clone'); const Color = require('../../util/color'); const formatMessage = require('format-message'); const MathUtil = require('../../util/math-util'); const RenderedTarget = require('../../sprites/rendered-target'); const log = require('../../util/log'); /** * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+cGVuLWljb248L3RpdGxlPjxnIHN0cm9rZT0iIzU3NUU3NSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik04Ljc1MyAzNC42MDJsLTQuMjUgMS43OCAxLjc4My00LjIzN2MxLjIxOC0yLjg5MiAyLjkwNy01LjQyMyA1LjAzLTcuNTM4TDMxLjA2NiA0LjkzYy44NDYtLjg0MiAyLjY1LS40MSA0LjAzMi45NjcgMS4zOCAxLjM3NSAxLjgxNiAzLjE3My45NyA0LjAxNUwxNi4zMTggMjkuNTljLTIuMTIzIDIuMTE2LTQuNjY0IDMuOC03LjU2NSA1LjAxMiIgZmlsbD0iI0ZGRiIvPjxwYXRoIGQ9Ik0yOS40MSA2LjExcy00LjQ1LTIuMzc4LTguMjAyIDUuNzcyYy0xLjczNCAzLjc2Ni00LjM1IDEuNTQ2LTQuMzUgMS41NDYiLz48cGF0aCBkPSJNMzYuNDIgOC44MjVjMCAuNDYzLS4xNC44NzMtLjQzMiAxLjE2NGwtOS4zMzUgOS4zYy4yODItLjI5LjQxLS42NjguNDEtMS4xMiAwLS44NzQtLjUwNy0xLjk2My0xLjQwNi0yLjg2OC0xLjM2Mi0xLjM1OC0zLjE0Ny0xLjgtNC4wMDItLjk5TDMwLjk5IDUuMDFjLjg0NC0uODQgMi42NS0uNDEgNC4wMzUuOTYuODk4LjkwNCAxLjM5NiAxLjk4MiAxLjM5NiAyLjg1NU0xMC41MTUgMzMuNzc0Yy0uNTczLjMwMi0xLjE1Ny41Ny0xLjc2NC44M0w0LjUgMzYuMzgybDEuNzg2LTQuMjM1Yy4yNTgtLjYwNC41My0xLjE4Ni44MzMtMS43NTcuNjkuMTgzIDEuNDQ4LjYyNSAyLjEwOCAxLjI4Mi42Ni42NTggMS4xMDIgMS40MTIgMS4yODcgMi4xMDIiIGZpbGw9IiM0Qzk3RkYiLz48cGF0aCBkPSJNMzYuNDk4IDguNzQ4YzAgLjQ2NC0uMTQuODc0LS40MzMgMS4xNjVsLTE5Ljc0MiAxOS42OGMtMi4xMyAyLjExLTQuNjczIDMuNzkzLTcuNTcyIDUuMDFMNC41IDM2LjM4bC45NzQtMi4zMTYgMS45MjUtLjgwOGMyLjg5OC0xLjIxOCA1LjQ0LTIuOSA3LjU3LTUuMDFsMTkuNzQzLTE5LjY4Yy4yOTItLjI5Mi40MzItLjcwMi40MzItMS4xNjUgMC0uNjQ2LS4yNy0xLjQtLjc4LTIuMTIyLjI1LjE3Mi41LjM3Ny43MzcuNjE0Ljg5OC45MDUgMS4zOTYgMS45ODMgMS4zOTYgMi44NTYiIGZpbGw9IiM1NzVFNzUiIG9wYWNpdHk9Ii4xNSIvPjxwYXRoIGQ9Ik0xOC40NSAxMi44M2MwIC41LS40MDQuOTA1LS45MDQuOTA1cy0uOTA1LS40MDUtLjkwNS0uOTA0YzAtLjUuNDA3LS45MDMuOTA2LS45MDMuNSAwIC45MDQuNDA0LjkwNC45MDR6IiBmaWxsPSIjNTc1RTc1Ii8+PC9nPjwvc3ZnPg=='; /** * Enum for pen color parameter values. * @readonly * @enum {string} */ const ColorParam = { COLOR: 'color', SATURATION: 'saturation', BRIGHTNESS: 'brightness', TRANSPARENCY: 'transparency' }; /** * @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} color - the current color (hue) 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 */ class Scratch3PenBlocks { constructor (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._onTargetCreated = this._onTargetCreated.bind(this); this._onTargetMoved = this._onTargetMoved.bind(this); runtime.on('targetWasCreated', this._onTargetCreated); } /** * The default pen state, to be used when a target has no existing pen state. * @type {PenState} */ static get DEFAULT_PEN_STATE () { return { penDown: false, color: 66.66, saturation: 100, brightness: 100, transparency: 0, _shade: 50, // Used only for legacy `change shade by` blocks 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} */ static get PEN_ORDER () { return 1; } /** * The minimum and maximum allowed pen size. * @type {{min: number, max: number}} */ static get PEN_SIZE_RANGE () { return {min: 1, max: 255}; } /** * The key to load & store a target's pen-related state. * @type {string} */ static get STATE_KEY () { return '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 */ _clampPenSize (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 */ _getPenLayerID () { 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 */ _getPenState (target) { let penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY); if (!penState) { penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE); target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState); } return penState; } /** * When a pen-using Target is cloned, clone the pen state. * @param {Target} newTarget - the newly created target. * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. * @listens Runtime#event:targetWasCreated * @private */ _onTargetCreated (newTarget, sourceTarget) { if (sourceTarget) { const penState = sourceTarget.getCustomState(Scratch3PenBlocks.STATE_KEY); if (penState) { newTarget.setCustomState(Scratch3PenBlocks.STATE_KEY, Clone.simple(penState)); if (penState.penDown) { newTarget.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); } } } } /** * 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. * @param {boolean} isForce - whether the movement was forced. * @private */ _onTargetMoved (target, oldX, oldY, isForce) { // Only move the pen if the movement isn't forced (ie. dragged). if (!isForce) { const penSkinId = this._getPenLayerID(); if (penSkinId >= 0) { const penState = this._getPenState(target); this.runtime.renderer.penLine(penSkinId, penState.penAttributes, oldX, oldY, target.x, target.y); this.runtime.requestRedraw(); } } } /** * Wrap a color input into the range (0,100). * @param {number} value - the value to be wrapped. * @returns {number} the wrapped value. * @private */ _wrapColor (value) { return MathUtil.wrapClamp(value, 0, 100); } /** * Initialize color parameters menu with localized strings * @returns {array} of the localized text and values for each menu element * @private */ _initColorParam () { return [ { text: formatMessage({ id: 'pen.colorMenu.color', default: 'color', description: 'label for color element in color picker for pen extension' }), value: ColorParam.COLOR }, { text: formatMessage({ id: 'pen.colorMenu.saturation', default: 'saturation', description: 'label for saturation element in color picker for pen extension' }), value: ColorParam.SATURATION }, { text: formatMessage({ id: 'pen.colorMenu.brightness', default: 'brightness', description: 'label for brightness element in color picker for pen extension' }), value: ColorParam.BRIGHTNESS }, { text: formatMessage({ id: 'pen.colorMenu.transparency', default: 'transparency', description: 'label for transparency element in color picker for pen extension' }), value: ColorParam.TRANSPARENCY } ]; } /** * Clamp a pen color parameter to the range (0,100). * @param {number} value - the value to be clamped. * @returns {number} the clamped value. * @private */ _clampColorParam (value) { return MathUtil.clamp(value, 0, 100); } /** * Convert an alpha value to a pen transparency value. * Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque. * Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent. * @param {number} alpha - the input alpha value. * @returns {number} the transparency value. * @private */ _alphaToTransparency (alpha) { return (1.0 - alpha) * 100.0; } /** * Convert a pen transparency value to an alpha value. * Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque. * Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent. * @param {number} transparency - the input transparency value. * @returns {number} the alpha value. * @private */ _transparencyToAlpha (transparency) { return 1.0 - (transparency / 100.0); } /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { return { id: 'pen', name: 'Pen', blockIconURI: blockIconURI, blocks: [ { opcode: 'clear', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.clear', default: 'clear', description: 'erase all pen trails and stamps' }) }, { opcode: 'stamp', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.stamp', default: 'stamp', description: 'render current costume on the background' }) }, { opcode: 'penDown', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.penDown', default: 'pen down', description: 'start leaving a trail when the sprite moves' }) }, { opcode: 'penUp', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.penUp', default: 'pen up', description: 'stop leaving a trail behind the sprite' }) }, { opcode: 'setPenColorToColor', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.setColor', default: 'set pen color to [COLOR]', description: 'set the pen color to a particular (RGB) value' }), arguments: { COLOR: { type: ArgumentType.COLOR } } }, { opcode: 'changePenColorParamBy', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.changeColorParam', default: 'change pen [COLOR_PARAM] by [VALUE]', description: 'change the state of a pen color parameter' }), arguments: { COLOR_PARAM: { type: ArgumentType.STRING, menu: 'colorParam', defaultValue: ColorParam.COLOR }, VALUE: { type: ArgumentType.NUMBER, defaultValue: 10 } } }, { opcode: 'setPenColorParamTo', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.setColorParam', default: 'set pen [COLOR_PARAM] to [VALUE]', description: 'set the state for a pen color parameter e.g. saturation' }), arguments: { COLOR_PARAM: { type: ArgumentType.STRING, menu: 'colorParam', defaultValue: ColorParam.COLOR }, VALUE: { type: ArgumentType.NUMBER, defaultValue: 50 } } }, { opcode: 'changePenSizeBy', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.changeSize', default: 'change pen size by [SIZE]', description: 'change the diameter of the trail left by a sprite' }), arguments: { SIZE: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, { opcode: 'setPenSizeTo', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.setSize', default: 'set pen size to [SIZE]', description: 'set the diameter of a trail left by a sprite' }), arguments: { SIZE: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, /* Legacy blocks, should not be shown in flyout */ { opcode: 'setPenShadeToNumber', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.setShade', default: 'set pen shade to [SHADE]', description: 'legacy pen blocks - set pen shade' }), arguments: { SHADE: { type: ArgumentType.NUMBER, defaultValue: 1 } }, hideFromPalette: true }, { opcode: 'changePenShadeBy', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.changeShade', default: 'change pen shade by [SHADE]', description: 'legacy pen blocks - change pen shade' }), arguments: { SHADE: { type: ArgumentType.NUMBER, defaultValue: 1 } }, hideFromPalette: true }, { opcode: 'setPenHueToNumber', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.setHue', default: 'set pen color to [HUE]', description: 'legacy pen blocks - set pen color to number' }), arguments: { HUE: { type: ArgumentType.NUMBER, defaultValue: 1 } }, hideFromPalette: true }, { opcode: 'getUpDown', text: 'pen down?', blockType: BlockType.BOOLEAN }, { opcode: 'changePenHueBy', blockType: BlockType.COMMAND, text: formatMessage({ id: 'pen.changeHue', default: 'change pen color by [HUE]', description: 'legacy pen blocks - change pen color' }), arguments: { HUE: { type: ArgumentType.NUMBER, defaultValue: 1 } }, hideFromPalette: true } ], menus: { colorParam: this._initColorParam() } }; } /** * The pen "clear" block clears the pen layer's contents. */ clear () { const 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. */ stamp (args, util) { const penSkinId = this._getPenLayerID(); if (penSkinId >= 0) { const 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. */ penDown (args, util) { const target = util.target; const penState = this._getPenState(target); if (!penState.penDown) { penState.penDown = true; target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); } const 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. */ penUp (args, util) { const target = util.target; const 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. * The transparency is reset to 0. * @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. */ setPenColorToColor (args, util) { const penState = this._getPenState(util.target); const rgb = Cast.toRgbColorObject(args.COLOR); const hsv = Color.rgbToHsv(rgb); penState.color = (hsv.h / 360) * 100; penState.saturation = hsv.s * 100; penState.brightness = hsv.v * 100; if (rgb.hasOwnProperty('a')) { penState.transparency = 100 * (1 - (rgb.a / 255.0)); } else { penState.transparency = 0; } // Set the legacy "shade" value the same way scratch 2 did. penState._shade = penState.brightness / 2; this._updatePenColor(penState); } /** * Update the cached color from the color, saturation, brightness and transparency values * in the provided PenState object. * @param {PenState} penState - the pen state to update. * @private */ _updatePenColor (penState) { const rgb = Color.hsvToRgb({ h: penState.color * 360 / 100, s: penState.saturation / 100, v: penState.brightness / 100 }); penState.penAttributes.color4f[0] = rgb.r / 255.0; penState.penAttributes.color4f[1] = rgb.g / 255.0; penState.penAttributes.color4f[2] = rgb.b / 255.0; penState.penAttributes.color4f[3] = this._transparencyToAlpha(penState.transparency); } /** * Set or change a single color parameter on the pen state, and update the pen color. * @param {ColorParam} param - the name of the color parameter to set or change. * @param {number} value - the value to set or change the param by. * @param {PenState} penState - the pen state to update. * @param {boolean} change - if true change param by value, if false set param to value. * @private */ _setOrChangeColorParam (param, value, penState, change) { switch (param) { case ColorParam.COLOR: penState.color = this._wrapColor(value + (change ? penState.color : 0)); break; case ColorParam.SATURATION: penState.saturation = this._clampColorParam(value + (change ? penState.saturation : 0)); break; case ColorParam.BRIGHTNESS: penState.brightness = this._clampColorParam(value + (change ? penState.brightness : 0)); break; case ColorParam.TRANSPARENCY: penState.transparency = this._clampColorParam(value + (change ? penState.transparency : 0)); break; default: log.warn(`Tried to set or change unknown color parameter: ${param}`); } this._updatePenColor(penState); } /** * The "change pen {ColorParam} by {number}" block changes one of the pen's color parameters * by a given amound. * @param {object} args - the block arguments. * @property {ColorParam} COLOR_PARAM - the name of the selected color parameter. * @property {number} VALUE - the amount to change the selected parameter by. * @param {object} util - utility object provided by the runtime. */ changePenColorParamBy (args, util) { const penState = this._getPenState(util.target); this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, true); } /** * The "set pen {ColorParam} to {number}" block sets one of the pen's color parameters * to a given amound. * @param {object} args - the block arguments. * @property {ColorParam} COLOR_PARAM - the name of the selected color parameter. * @property {number} VALUE - the amount to set the selected parameter to. * @param {object} util - utility object provided by the runtime. */ setPenColorParamTo (args, util) { const penState = this._getPenState(util.target); this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, false); } /** * 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. */ changePenSizeBy (args, util) { const 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. */ setPenSizeTo (args, util) { const penAttributes = this._getPenState(util.target).penAttributes; penAttributes.diameter = this._clampPenSize(Cast.toNumber(args.SIZE)); } /* LEGACY OPCODES */ /** * Scratch 2 "hue" param is equivelant to twice the new "color" param. * @param {object} args - the block arguments. * @property {number} HUE - the amount to set the hue to. * @param {object} util - utility object provided by the runtime. */ setPenHueToNumber (args, util) { const penState = this._getPenState(util.target); const hueValue = Cast.toNumber(args.HUE); const colorValue = hueValue / 2; this._setOrChangeColorParam(ColorParam.COLOR, colorValue, penState, false); this._legacyUpdatePenColor(penState); } /** * Scratch 2 "hue" param is equivelant to twice the new "color" param. * @param {object} args - the block arguments. * @property {number} HUE - the amount of desired hue change. * @param {object} util - utility object provided by the runtime. */ changePenHueBy (args, util) { const penState = this._getPenState(util.target); const hueChange = Cast.toNumber(args.HUE); const colorChange = hueChange / 2; this._setOrChangeColorParam(ColorParam.COLOR, colorChange, penState, true); this._legacyUpdatePenColor(penState); } /** * Use legacy "set shade" code to calculate RGB value for shade, * then convert back to HSV and store those components. * It is important to also track the given shade in penState._shade * because it cannot be accurately backed out of the new HSV later. * @param {object} args - the block arguments. * @property {number} SHADE - the amount to set the shade to. * @param {object} util - utility object provided by the runtime. */ setPenShadeToNumber (args, util) { const penState = this._getPenState(util.target); let newShade = Cast.toNumber(args.SHADE); // Wrap clamp the new shade value the way scratch 2 did. newShade = newShade % 200; if (newShade < 0) newShade += 200; // And store the shade that was used to compute this new color for later use. penState._shade = newShade; this._legacyUpdatePenColor(penState); } /** * Because "shade" cannot be backed out of hsv consistently, use the previously * stored penState._shade to make the shade change. * @param {object} args - the block arguments. * @property {number} SHADE - the amount of desired shade change. * @param {object} util - utility object provided by the runtime. */ changePenShadeBy (args, util) { const penState = this._getPenState(util.target); const shadeChange = Cast.toNumber(args.SHADE); this.setPenShadeToNumber({SHADE: penState._shade + shadeChange}, util); } /** * Update the pen state's color from its hue & shade values, Scratch 2.0 style. * @param {object} penState - update the HSV & RGB values in this pen state from its hue & shade values. * @private */ _legacyUpdatePenColor (penState) { // Create the new color in RGB using the scratch 2 "shade" model let rgb = Color.hsvToRgb({h: penState.color * 360 / 100, s: 1, v: 1}); const 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); } // Update the pen state according to new color const hsv = Color.rgbToHsv(rgb); penState.color = 100 * hsv.h / 360; penState.saturation = 100 * hsv.s; penState.brightness = 100 * hsv.v; this._updatePenColor(penState); } getUpDown () { return penState.penDown; } } module.exports = Scratch3PenBlocks;