|
| 1 | +const twgl = require('twgl.js'); |
| 2 | + |
| 3 | +const TextWrapper = require('./util/text-wrapper'); |
| 4 | +const CanvasMeasurementProvider = require('./util/canvas-measurement-provider'); |
| 5 | +const Skin = require('./Skin'); |
| 6 | + |
| 7 | +const BubbleStyle = { |
| 8 | + MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text |
| 9 | + |
| 10 | + MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble |
| 11 | + STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill |
| 12 | + PADDING: 10, // Padding around the text area |
| 13 | + CORNER_RADIUS: 16, // Radius of the rounded corners |
| 14 | + TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant. |
| 15 | + |
| 16 | + FONT: 'Helvetica', // Font to render the text with |
| 17 | + FONT_SIZE: 14, // Font size, in Scratch pixels |
| 18 | + FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size |
| 19 | + LINE_HEIGHT: 16, // Spacing between each line of text |
| 20 | + |
| 21 | + COLORS: { |
| 22 | + BUBBLE_FILL: 'white', |
| 23 | + BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)', |
| 24 | + TEXT_FILL: '#575E75' |
| 25 | + } |
| 26 | +}; |
| 27 | + |
| 28 | +class TextBubbleSkin extends Skin { |
| 29 | + /** |
| 30 | + * Create a new text bubble skin. |
| 31 | + * @param {!int} id - The ID for this Skin. |
| 32 | + * @param {!RenderWebGL} renderer - The renderer which will use this skin. |
| 33 | + * @constructor |
| 34 | + * @extends Skin |
| 35 | + */ |
| 36 | + constructor (id, renderer) { |
| 37 | + super(id); |
| 38 | + |
| 39 | + /** @type {RenderWebGL} */ |
| 40 | + this._renderer = renderer; |
| 41 | + |
| 42 | + /** @type {HTMLCanvasElement} */ |
| 43 | + this._canvas = document.createElement('canvas'); |
| 44 | + |
| 45 | + /** @type {WebGLTexture} */ |
| 46 | + this._texture = null; |
| 47 | + |
| 48 | + /** @type {Array<number>} */ |
| 49 | + this._size = [0, 0]; |
| 50 | + |
| 51 | + /** @type {number} */ |
| 52 | + this._renderedScale = 0; |
| 53 | + |
| 54 | + /** @type {Array<string>} */ |
| 55 | + this._lines = []; |
| 56 | + |
| 57 | + this._textSize = {width: 0, height: 0}; |
| 58 | + this._textAreaSize = {width: 0, height: 0}; |
| 59 | + |
| 60 | + /** @type {string} */ |
| 61 | + this._bubbleType = ''; |
| 62 | + |
| 63 | + /** @type {boolean} */ |
| 64 | + this._pointsLeft = false; |
| 65 | + |
| 66 | + /** @type {boolean} */ |
| 67 | + this._textDirty = true; |
| 68 | + |
| 69 | + /** @type {boolean} */ |
| 70 | + this._textureDirty = true; |
| 71 | + |
| 72 | + this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d')); |
| 73 | + this.textWrapper = new TextWrapper(this.measurementProvider); |
| 74 | + |
| 75 | + this._restyleCanvas(); |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * Dispose of this object. Do not use it after calling this method. |
| 80 | + */ |
| 81 | + dispose () { |
| 82 | + if (this._texture) { |
| 83 | + this._renderer.gl.deleteTexture(this._texture); |
| 84 | + this._texture = null; |
| 85 | + } |
| 86 | + this._canvas = null; |
| 87 | + super.dispose(); |
| 88 | + } |
| 89 | + |
| 90 | + /** |
| 91 | + * @return {Array<number>} the dimensions, in Scratch units, of this skin. |
| 92 | + */ |
| 93 | + get size () { |
| 94 | + if (this._textDirty) { |
| 95 | + this._reflowLines(); |
| 96 | + } |
| 97 | + return this._size; |
| 98 | + } |
| 99 | + |
| 100 | + /** |
| 101 | + * Set parameters for this text bubble. |
| 102 | + * @param {!string} type - either "say" or "think". |
| 103 | + * @param {!string} text - the text for the bubble. |
| 104 | + * @param {!boolean} pointsLeft - which side the bubble is pointing. |
| 105 | + */ |
| 106 | + setTextBubble (type, text, pointsLeft) { |
| 107 | + this._text = text; |
| 108 | + this._bubbleType = type; |
| 109 | + this._pointsLeft = pointsLeft; |
| 110 | + |
| 111 | + this._textDirty = true; |
| 112 | + this._textureDirty = true; |
| 113 | + this.emit(Skin.Events.WasAltered); |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * Re-style the canvas after resizing it. This is necessary to ensure proper text measurement. |
| 118 | + */ |
| 119 | + _restyleCanvas () { |
| 120 | + this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`; |
| 121 | + } |
| 122 | + |
| 123 | + /** |
| 124 | + * Update the array of wrapped lines and the text dimensions. |
| 125 | + */ |
| 126 | + _reflowLines () { |
| 127 | + this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text); |
| 128 | + |
| 129 | + // Measure width of longest line to avoid extra-wide bubbles |
| 130 | + let longestLine = 0; |
| 131 | + for (const line of this._lines) { |
| 132 | + longestLine = Math.max(longestLine, this.measurementProvider.measureText(line)); |
| 133 | + } |
| 134 | + |
| 135 | + this._textSize.width = longestLine; |
| 136 | + this._textSize.height = BubbleStyle.LINE_HEIGHT * this._lines.length; |
| 137 | + |
| 138 | + // Calculate the canvas-space sizes of the padded text area and full text bubble |
| 139 | + const paddedWidth = Math.max(this._textSize.width, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2); |
| 140 | + const paddedHeight = this._textSize.height + (BubbleStyle.PADDING * 2); |
| 141 | + |
| 142 | + this._textAreaSize.width = paddedWidth; |
| 143 | + this._textAreaSize.height = paddedHeight; |
| 144 | + |
| 145 | + this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH; |
| 146 | + this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT; |
| 147 | + |
| 148 | + this._textDirty = false; |
| 149 | + } |
| 150 | + |
| 151 | + /** |
| 152 | + * Render this text bubble at a certain scale, using the current parameters, to the canvas. |
| 153 | + * @param {number} scale The scale to render the bubble at |
| 154 | + */ |
| 155 | + _renderTextBubble (scale) { |
| 156 | + const ctx = this._canvas.getContext('2d'); |
| 157 | + |
| 158 | + if (this._textDirty) { |
| 159 | + this._reflowLines(); |
| 160 | + } |
| 161 | + |
| 162 | + // Calculate the canvas-space sizes of the padded text area and full text bubble |
| 163 | + const paddedWidth = this._textAreaSize.width; |
| 164 | + const paddedHeight = this._textAreaSize.height; |
| 165 | + |
| 166 | + // Resize the canvas to the correct screen-space size |
| 167 | + this._canvas.width = Math.ceil(this._size[0] * scale); |
| 168 | + this._canvas.height = Math.ceil(this._size[1] * scale); |
| 169 | + this._restyleCanvas(); |
| 170 | + |
| 171 | + // Reset the transform before clearing to ensure 100% clearage |
| 172 | + ctx.setTransform(1, 0, 0, 1, 0, 0); |
| 173 | + ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); |
| 174 | + |
| 175 | + ctx.scale(scale, scale); |
| 176 | + ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5); |
| 177 | + |
| 178 | + // If the text bubble points leftward, flip the canvas |
| 179 | + ctx.save(); |
| 180 | + if (this._pointsLeft) { |
| 181 | + ctx.scale(-1, 1); |
| 182 | + ctx.translate(-paddedWidth, 0); |
| 183 | + } |
| 184 | + |
| 185 | + // Draw the bubble's rounded borders |
| 186 | + ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight); |
| 187 | + ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS); |
| 188 | + ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS); |
| 189 | + ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS); |
| 190 | + ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight, |
| 191 | + BubbleStyle.CORNER_RADIUS); |
| 192 | + |
| 193 | + // Translate the canvas so we don't have to do a bunch of width/height arithmetic |
| 194 | + ctx.save(); |
| 195 | + ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight); |
| 196 | + |
| 197 | + // Draw the bubble's "tail" |
| 198 | + if (this._bubbleType === 'say') { |
| 199 | + // For a speech bubble, draw one swoopy thing |
| 200 | + ctx.bezierCurveTo(0, 4, 4, 8, 4, 10); |
| 201 | + ctx.arcTo(4, 12, 2, 12, 2); |
| 202 | + ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0); |
| 203 | + |
| 204 | + ctx.closePath(); |
| 205 | + } else { |
| 206 | + // For a thinking bubble, draw a partial circle attached to the bubble... |
| 207 | + ctx.arc(-16, 0, 4, 0, Math.PI); |
| 208 | + |
| 209 | + ctx.closePath(); |
| 210 | + |
| 211 | + // and two circles detached from it |
| 212 | + ctx.moveTo(-7, 7.25); |
| 213 | + ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2); |
| 214 | + |
| 215 | + ctx.moveTo(0, 9.5); |
| 216 | + ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2); |
| 217 | + } |
| 218 | + |
| 219 | + // Un-translate the canvas and fill + stroke the text bubble |
| 220 | + ctx.restore(); |
| 221 | + |
| 222 | + ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL; |
| 223 | + ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE; |
| 224 | + ctx.lineWidth = BubbleStyle.STROKE_WIDTH; |
| 225 | + |
| 226 | + ctx.stroke(); |
| 227 | + ctx.fill(); |
| 228 | + |
| 229 | + // Un-flip the canvas if it was flipped |
| 230 | + ctx.restore(); |
| 231 | + |
| 232 | + // Draw each line of text |
| 233 | + ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL; |
| 234 | + ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`; |
| 235 | + const lines = this._lines; |
| 236 | + for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { |
| 237 | + const line = lines[lineNumber]; |
| 238 | + ctx.fillText( |
| 239 | + line, |
| 240 | + BubbleStyle.PADDING, |
| 241 | + BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) + |
| 242 | + (BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE) |
| 243 | + ); |
| 244 | + } |
| 245 | + |
| 246 | + this._renderedScale = scale; |
| 247 | + } |
| 248 | + |
| 249 | + /** |
| 250 | + * @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range. |
| 251 | + * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. |
| 252 | + */ |
| 253 | + getTexture (scale) { |
| 254 | + // The texture only ever gets uniform scale. Take the larger of the two axes. |
| 255 | + const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100; |
| 256 | + const requestedScale = scaleMax / 100; |
| 257 | + |
| 258 | + // If we already rendered the text bubble at this scale, we can skip re-rendering it. |
| 259 | + if (this._textureDirty || this._renderedScale !== requestedScale) { |
| 260 | + this._renderTextBubble(requestedScale); |
| 261 | + this._textureDirty = false; |
| 262 | + |
| 263 | + const context = this._canvas.getContext('2d'); |
| 264 | + const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height); |
| 265 | + |
| 266 | + const gl = this._renderer.gl; |
| 267 | + |
| 268 | + if (this._texture === null) { |
| 269 | + const textureOptions = { |
| 270 | + auto: true, |
| 271 | + wrap: gl.CLAMP_TO_EDGE, |
| 272 | + src: textureData |
| 273 | + }; |
| 274 | + |
| 275 | + this._texture = twgl.createTexture(gl, textureOptions); |
| 276 | + } |
| 277 | + |
| 278 | + gl.bindTexture(gl.TEXTURE_2D, this._texture); |
| 279 | + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData); |
| 280 | + this._silhouette.update(textureData); |
| 281 | + } |
| 282 | + |
| 283 | + return this._texture; |
| 284 | + } |
| 285 | +} |
| 286 | + |
| 287 | +module.exports = TextBubbleSkin; |
0 commit comments