UNPKG

9.83 kBJavaScriptView Raw
1const twgl = require('twgl.js');
2
3const TextWrapper = require('./util/text-wrapper');
4const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
5const Skin = require('./Skin');
6
7const 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
28class 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 {Array<number>} */
46 this._size = [0, 0];
47
48 /** @type {number} */
49 this._renderedScale = 0;
50
51 /** @type {Array<string>} */
52 this._lines = [];
53
54 /** @type {object} */
55 this._textAreaSize = {width: 0, height: 0};
56
57 /** @type {string} */
58 this._bubbleType = '';
59
60 /** @type {boolean} */
61 this._pointsLeft = false;
62
63 /** @type {boolean} */
64 this._textDirty = true;
65
66 /** @type {boolean} */
67 this._textureDirty = true;
68
69 this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d'));
70 this.textWrapper = new TextWrapper(this.measurementProvider);
71
72 this._restyleCanvas();
73 }
74
75 /**
76 * Dispose of this object. Do not use it after calling this method.
77 */
78 dispose () {
79 if (this._texture) {
80 this._renderer.gl.deleteTexture(this._texture);
81 this._texture = null;
82 }
83 this._canvas = null;
84 super.dispose();
85 }
86
87 /**
88 * @return {Array<number>} the dimensions, in Scratch units, of this skin.
89 */
90 get size () {
91 if (this._textDirty) {
92 this._reflowLines();
93 }
94 return this._size;
95 }
96
97 /**
98 * Set parameters for this text bubble.
99 * @param {!string} type - either "say" or "think".
100 * @param {!string} text - the text for the bubble.
101 * @param {!boolean} pointsLeft - which side the bubble is pointing.
102 */
103 setTextBubble (type, text, pointsLeft) {
104 this._text = text;
105 this._bubbleType = type;
106 this._pointsLeft = pointsLeft;
107
108 this._textDirty = true;
109 this._textureDirty = true;
110 this.emit(Skin.Events.WasAltered);
111 }
112
113 /**
114 * Re-style the canvas after resizing it. This is necessary to ensure proper text measurement.
115 */
116 _restyleCanvas () {
117 this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
118 }
119
120 /**
121 * Update the array of wrapped lines and the text dimensions.
122 */
123 _reflowLines () {
124 this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text);
125
126 // Measure width of longest line to avoid extra-wide bubbles
127 let longestLineWidth = 0;
128 for (const line of this._lines) {
129 longestLineWidth = Math.max(longestLineWidth, this.measurementProvider.measureText(line));
130 }
131
132 // Calculate the canvas-space sizes of the padded text area and full text bubble
133 const paddedWidth = Math.max(longestLineWidth, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2);
134 const paddedHeight = (BubbleStyle.LINE_HEIGHT * this._lines.length) + (BubbleStyle.PADDING * 2);
135
136 this._textAreaSize.width = paddedWidth;
137 this._textAreaSize.height = paddedHeight;
138
139 this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH;
140 this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT;
141
142 this._textDirty = false;
143 }
144
145 /**
146 * Render this text bubble at a certain scale, using the current parameters, to the canvas.
147 * @param {number} scale The scale to render the bubble at
148 */
149 _renderTextBubble (scale) {
150 const ctx = this._canvas.getContext('2d');
151
152 if (this._textDirty) {
153 this._reflowLines();
154 }
155
156 // Calculate the canvas-space sizes of the padded text area and full text bubble
157 const paddedWidth = this._textAreaSize.width;
158 const paddedHeight = this._textAreaSize.height;
159
160 // Resize the canvas to the correct screen-space size
161 this._canvas.width = Math.ceil(this._size[0] * scale);
162 this._canvas.height = Math.ceil(this._size[1] * scale);
163 this._restyleCanvas();
164
165 // Reset the transform before clearing to ensure 100% clearage
166 ctx.setTransform(1, 0, 0, 1, 0, 0);
167 ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
168
169 ctx.scale(scale, scale);
170 ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5);
171
172 // If the text bubble points leftward, flip the canvas
173 ctx.save();
174 if (this._pointsLeft) {
175 ctx.scale(-1, 1);
176 ctx.translate(-paddedWidth, 0);
177 }
178
179 // Draw the bubble's rounded borders
180 ctx.beginPath();
181 ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight);
182 ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS);
183 ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS);
184 ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS);
185 ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight,
186 BubbleStyle.CORNER_RADIUS);
187
188 // Translate the canvas so we don't have to do a bunch of width/height arithmetic
189 ctx.save();
190 ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight);
191
192 // Draw the bubble's "tail"
193 if (this._bubbleType === 'say') {
194 // For a speech bubble, draw one swoopy thing
195 ctx.bezierCurveTo(0, 4, 4, 8, 4, 10);
196 ctx.arcTo(4, 12, 2, 12, 2);
197 ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0);
198
199 ctx.closePath();
200 } else {
201 // For a thinking bubble, draw a partial circle attached to the bubble...
202 ctx.arc(-16, 0, 4, 0, Math.PI);
203
204 ctx.closePath();
205
206 // and two circles detached from it
207 ctx.moveTo(-7, 7.25);
208 ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2);
209
210 ctx.moveTo(0, 9.5);
211 ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2);
212 }
213
214 // Un-translate the canvas and fill + stroke the text bubble
215 ctx.restore();
216
217 ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL;
218 ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE;
219 ctx.lineWidth = BubbleStyle.STROKE_WIDTH;
220
221 ctx.stroke();
222 ctx.fill();
223
224 // Un-flip the canvas if it was flipped
225 ctx.restore();
226
227 // Draw each line of text
228 ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL;
229 ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
230 const lines = this._lines;
231 for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
232 const line = lines[lineNumber];
233 ctx.fillText(
234 line,
235 BubbleStyle.PADDING,
236 BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) +
237 (BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE)
238 );
239 }
240
241 this._renderedScale = scale;
242 }
243
244 updateSilhouette (scale = [100, 100]) {
245 // Ensure a silhouette exists.
246 this.getTexture(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: false,
271 wrap: gl.CLAMP_TO_EDGE
272 };
273
274 this._texture = twgl.createTexture(gl, textureOptions);
275 }
276
277 this._setTexture(textureData);
278 }
279
280 return this._texture;
281 }
282}
283
284module.exports = TextBubbleSkin;