@scratch/scratch-render 11.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Skin.js ADDED
@@ -0,0 +1,235 @@
1
+ const EventEmitter = require('events');
2
+
3
+ const twgl = require('twgl.js');
4
+
5
+ const RenderConstants = require('./RenderConstants');
6
+ const Silhouette = require('./Silhouette');
7
+
8
+ class Skin extends EventEmitter {
9
+ /**
10
+ * Create a Skin, which stores and/or generates textures for use in rendering.
11
+ * @param {int} id - The unique ID for this Skin.
12
+ * @constructor
13
+ */
14
+ constructor (id) {
15
+ super();
16
+
17
+ /** @type {int} */
18
+ this._id = id;
19
+
20
+ /** @type {Vec3} */
21
+ this._rotationCenter = twgl.v3.create(0, 0);
22
+
23
+ /** @type {WebGLTexture} */
24
+ this._texture = null;
25
+
26
+ /**
27
+ * The uniforms to be used by the vertex and pixel shaders.
28
+ * Some of these are used by other parts of the renderer as well.
29
+ * @type {Object.<string,*>}
30
+ * @private
31
+ */
32
+ this._uniforms = {
33
+ /**
34
+ * The nominal (not necessarily current) size of the current skin.
35
+ * @type {Array<number>}
36
+ */
37
+ u_skinSize: [0, 0],
38
+
39
+ /**
40
+ * The actual WebGL texture object for the skin.
41
+ * @type {WebGLTexture}
42
+ */
43
+ u_skin: null
44
+ };
45
+
46
+ /**
47
+ * A silhouette to store touching data, skins are responsible for keeping it up to date.
48
+ * @private
49
+ */
50
+ this._silhouette = new Silhouette();
51
+
52
+ this.setMaxListeners(RenderConstants.SKIN_SHARE_SOFT_LIMIT);
53
+ }
54
+
55
+ /**
56
+ * Dispose of this object. Do not use it after calling this method.
57
+ */
58
+ dispose () {
59
+ this._id = RenderConstants.ID_NONE;
60
+ }
61
+
62
+ /**
63
+ * @return {int} the unique ID for this Skin.
64
+ */
65
+ get id () {
66
+ return this._id;
67
+ }
68
+
69
+ /**
70
+ * @returns {Vec3} the origin, in object space, about which this Skin should rotate.
71
+ */
72
+ get rotationCenter () {
73
+ return this._rotationCenter;
74
+ }
75
+
76
+ /**
77
+ * @abstract
78
+ * @return {Array<number>} the "native" size, in texels, of this skin.
79
+ */
80
+ get size () {
81
+ return [0, 0];
82
+ }
83
+
84
+ /**
85
+ * Should this skin's texture be filtered with nearest-neighbor or linear interpolation at the given scale?
86
+ * @param {?Array<Number>} scale The screen-space X and Y scaling factors at which this skin's texture will be
87
+ * displayed, as percentages (100 means 1 "native size" unit is 1 screen pixel; 200 means 2 screen pixels, etc).
88
+ * @param {Drawable} drawable The drawable that this skin's texture will be applied to.
89
+ * @return {boolean} True if this skin's texture, as returned by {@link getTexture}, should be filtered with
90
+ * nearest-neighbor interpolation.
91
+ */
92
+ // eslint-disable-next-line no-unused-vars
93
+ useNearest (scale, drawable) {
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Get the center of the current bounding box
99
+ * @return {Array<number>} the center of the current bounding box
100
+ */
101
+ calculateRotationCenter () {
102
+ return [this.size[0] / 2, this.size[1] / 2];
103
+ }
104
+
105
+ /**
106
+ * @abstract
107
+ * @param {Array<number>} scale - The scaling factors to be used.
108
+ * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size.
109
+ */
110
+ // eslint-disable-next-line no-unused-vars
111
+ getTexture (scale) {
112
+ return this._emptyImageTexture;
113
+ }
114
+
115
+ /**
116
+ * Get the bounds of the drawable for determining its fenced position.
117
+ * @param {Array<number>} drawable - The Drawable instance this skin is using.
118
+ * @param {?Rectangle} result - Optional destination for bounds calculation.
119
+ * @return {!Rectangle} The drawable's bounds. For compatibility with Scratch 2, we always use getAABB.
120
+ */
121
+ getFenceBounds (drawable, result) {
122
+ return drawable.getAABB(result);
123
+ }
124
+
125
+ /**
126
+ * Update and returns the uniforms for this skin.
127
+ * @param {Array<number>} scale - The scaling factors to be used.
128
+ * @returns {object.<string, *>} the shader uniforms to be used when rendering with this Skin.
129
+ */
130
+ getUniforms (scale) {
131
+ this._uniforms.u_skin = this.getTexture(scale);
132
+ this._uniforms.u_skinSize = this.size;
133
+ return this._uniforms;
134
+ }
135
+
136
+ /**
137
+ * If the skin defers silhouette operations until the last possible minute,
138
+ * this will be called before isTouching uses the silhouette.
139
+ * @abstract
140
+ */
141
+ updateSilhouette () {}
142
+
143
+ /**
144
+ * Set this skin's texture to the given image.
145
+ * @param {ImageData|HTMLCanvasElement} textureData - The canvas or image data to set the texture to.
146
+ */
147
+ _setTexture (textureData) {
148
+ const gl = this._renderer.gl;
149
+
150
+ gl.bindTexture(gl.TEXTURE_2D, this._texture);
151
+ // Premultiplied alpha is necessary for proper blending.
152
+ // See http://www.realtimerendering.com/blog/gpus-prefer-premultiplication/
153
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
154
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
155
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
156
+
157
+ this._silhouette.update(textureData);
158
+ }
159
+
160
+ /**
161
+ * Set the contents of this skin to an empty skin.
162
+ * @fires Skin.event:WasAltered
163
+ */
164
+ setEmptyImageData () {
165
+ // Free up the current reference to the _texture
166
+ this._texture = null;
167
+
168
+ if (!this._emptyImageData) {
169
+ // Create a transparent pixel
170
+ this._emptyImageData = new ImageData(1, 1);
171
+
172
+ // Create a new texture and update the silhouette
173
+ const gl = this._renderer.gl;
174
+
175
+ const textureOptions = {
176
+ auto: true,
177
+ wrap: gl.CLAMP_TO_EDGE,
178
+ src: this._emptyImageData
179
+ };
180
+
181
+ // Note: we're using _emptyImageTexture here instead of _texture
182
+ // so that we can cache this empty texture for later use as needed.
183
+ // this._texture can get modified by other skins (e.g. BitmapSkin
184
+ // and SVGSkin, so we can't use that same field for caching)
185
+ this._emptyImageTexture = twgl.createTexture(gl, textureOptions);
186
+ }
187
+
188
+ this._rotationCenter[0] = 0;
189
+ this._rotationCenter[1] = 0;
190
+
191
+ this._silhouette.update(this._emptyImageData);
192
+ this.emit(Skin.Events.WasAltered);
193
+ }
194
+
195
+ /**
196
+ * Does this point touch an opaque or translucent point on this skin?
197
+ * Nearest Neighbor version
198
+ * The caller is responsible for ensuring this skin's silhouette is up-to-date.
199
+ * @see updateSilhouette
200
+ * @see Drawable.updateCPURenderAttributes
201
+ * @param {twgl.v3} vec A texture coordinate.
202
+ * @return {boolean} Did it touch?
203
+ */
204
+ isTouchingNearest (vec) {
205
+ return this._silhouette.isTouchingNearest(vec);
206
+ }
207
+
208
+ /**
209
+ * Does this point touch an opaque or translucent point on this skin?
210
+ * Linear Interpolation version
211
+ * The caller is responsible for ensuring this skin's silhouette is up-to-date.
212
+ * @see updateSilhouette
213
+ * @see Drawable.updateCPURenderAttributes
214
+ * @param {twgl.v3} vec A texture coordinate.
215
+ * @return {boolean} Did it touch?
216
+ */
217
+ isTouchingLinear (vec) {
218
+ return this._silhouette.isTouchingLinear(vec);
219
+ }
220
+
221
+ }
222
+
223
+ /**
224
+ * These are the events which can be emitted by instances of this class.
225
+ * @enum {string}
226
+ */
227
+ Skin.Events = {
228
+ /**
229
+ * Emitted when anything about the Skin has been altered, such as the appearance or rotation center.
230
+ * @event Skin.event:WasAltered
231
+ */
232
+ WasAltered: 'WasAltered'
233
+ };
234
+
235
+ module.exports = Skin;
@@ -0,0 +1,284 @@
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 {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
+
284
+ module.exports = TextBubbleSkin;
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const RenderWebGL = require('./RenderWebGL');
2
+
3
+ /**
4
+ * Export for NPM & Node.js
5
+ * @type {RenderWebGL}
6
+ */
7
+ module.exports = RenderWebGL;
@@ -0,0 +1,37 @@
1
+ // Adapted from code by Simon Sarris: http://stackoverflow.com/a/10450761
2
+ const getMousePos = function (event, element) {
3
+ const stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(element, null).paddingLeft, 10) || 0;
4
+ const stylePaddingTop = parseInt(document.defaultView.getComputedStyle(element, null).paddingTop, 10) || 0;
5
+ const styleBorderLeft = parseInt(document.defaultView.getComputedStyle(element, null).borderLeftWidth, 10) || 0;
6
+ const styleBorderTop = parseInt(document.defaultView.getComputedStyle(element, null).borderTopWidth, 10) || 0;
7
+
8
+ // Some pages have fixed-position bars at the top or left of the page
9
+ // They will mess up mouse coordinates and this fixes that
10
+ const html = document.body.parentNode;
11
+ const htmlTop = html.offsetTop;
12
+ const htmlLeft = html.offsetLeft;
13
+
14
+ // Compute the total offset. It's possible to cache this if you want
15
+ let offsetX = 0;
16
+ let offsetY = 0;
17
+ if (typeof element.offsetParent !== 'undefined') {
18
+ do {
19
+ offsetX += element.offsetLeft;
20
+ offsetY += element.offsetTop;
21
+ } while ((element = element.offsetParent));
22
+ }
23
+
24
+ // Add padding and border style widths to offset
25
+ // Also add the <html> offsets in case there's a position:fixed bar
26
+ // This part is not strictly necessary, it depends on your styling
27
+ offsetX += stylePaddingLeft + styleBorderLeft + htmlLeft;
28
+ offsetY += stylePaddingTop + styleBorderTop + htmlTop;
29
+
30
+ // We return a simple javascript object with x and y defined
31
+ return {
32
+ x: event.pageX - offsetX,
33
+ y: event.pageY - offsetY
34
+ };
35
+ };
36
+
37
+ module.exports = getMousePos;
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Scratch WebGL rendering demo</title>
6
+ <link rel="stylesheet" type="text/css" href="style.css">
7
+ </head>
8
+ <body>
9
+ <canvas id="scratch-stage" width="10" height="10"></canvas>
10
+ <canvas id="debug-canvas" width="10" height="10"></canvas>
11
+ <p>
12
+ <label for="fudgeproperty">Property to tweak:</label>
13
+ <select id="fudgeproperty">
14
+ <option value="posx">Position X</option>
15
+ <option value="posy">Position Y</option>
16
+ <option value="direction">Direction</option>
17
+ <option value="scalex">Scale X</option>
18
+ <option value="scaley">Scale Y</option>
19
+ <option value="scaleboth">Scale (both dimensions)</option>
20
+ <option value="color">Color</option>
21
+ <option value="fisheye">Fisheye</option>
22
+ <option value="whirl">Whirl</option>
23
+ <option value="pixelate">Pixelate</option>
24
+ <option value="mosaic">Mosaic</option>
25
+ <option value="brightness">Brightness</option>
26
+ <option value="ghost">Ghost</option>
27
+ </select>
28
+ <label for="fudge">Property Value:</label>
29
+ <input type="range" id="fudge" style="width:50%" value="90" min="-90" max="270" step="any">
30
+ </p>
31
+ <p>
32
+ <label for="stage-scale">Stage scale:</label>
33
+ <input type="range" style="width:50%" id="stage-scale" value="1" min="1" max="2.5" step="any">
34
+ </p>
35
+ <p>
36
+ <label for="fudgeMin">Min:</label><input id="fudgeMin" type="number" value="0">
37
+ <label for="fudgeMax">Max:</label><input id="fudgeMax" type="number" value="200">
38
+ </p>
39
+ <script src="playground.js"></script>
40
+ </body>
41
+ </html>