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 MathUtil = require('../util/math-util'); const RenderedTarget = require('../sprites/rendered-target'); const log = require('../util/log'); /** * Enum for pen color parameters. * @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, 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. * @private */ _onTargetMoved (target, oldX, oldY) { 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(); } } _wrapColor (value) { return MathUtil.wrapClamp(value, 0, 100); } /** * 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', blocks: [ { opcode: 'clear', blockType: BlockType.COMMAND }, { opcode: 'stamp', blockType: BlockType.COMMAND }, { opcode: 'penDown', blockType: BlockType.COMMAND, text: 'pen down' }, { opcode: 'penUp', blockType: BlockType.COMMAND, text: 'pen up' }, { opcode: 'setPenColorToColor', blockType: BlockType.COMMAND, text: 'set pen color to [COLOR]', arguments: { COLOR: { type: ArgumentType.COLOR } } }, { opcode: 'changePenColorParamBy', blockType: BlockType.COMMAND, text: 'change pen [COLOR_PARAM] by [VALUE]', arguments: { COLOR_PARAM: { type: ArgumentType.STRING, menu: 'colorParam', defaultValue: ColorParam.COLOR }, VALUE: { type: ArgumentType.NUMBER, defaultValue: 10 } } }, { opcode: 'setPenColorParamTo', blockType: BlockType.COMMAND, text: 'set pen [COLOR_PARAM] to [VALUE]', arguments: { COLOR_PARAM: { type: ArgumentType.STRING, menu: 'colorParam', defaultValue: ColorParam.COLOR }, VALUE: { type: ArgumentType.NUMBER, defaultValue: 50 } } }, { opcode: 'changePenSizeBy', blockType: BlockType.COMMAND, text: 'change pen size by [SIZE]', arguments: { SIZE: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, { opcode: 'setPenSizeTo', blockType: BlockType.COMMAND, text: 'set pen size to [SIZE]', arguments: { SIZE: { type: ArgumentType.NUMBER, defaultValue: 1 } } } ], menus: { colorParam: [ColorParam.COLOR, ColorParam.SATURATION, ColorParam.BRIGHTNESS, ColorParam.TRANSPARENCY] } }; } /** * 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; penState.transparency = 0; 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) { let 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); } _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); } changePenColorParamBy (args, util) { const penState = this._getPenState(util.target); this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, true); } 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)); } } module.exports = Scratch3PenBlocks;