littlejsengine 1.18.28 → 1.19.3

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/engineDraw.js CHANGED
@@ -1,1511 +1,1673 @@
1
- /**
2
- * LittleJS Drawing System
3
- * - Hybrid rendering with both Canvas2D and WebGL support
4
- * - Optimized tile sheet sprite rendering using WebGL batching
5
- * - Primitive drawing for polygons, ellipses, and lines
6
- * - Tile-based rendering with TileInfo and TextureInfo classes
7
- * - Text rendering with custom fonts and ImageFont support
8
- * - Color and additive color blending for effects
9
- * - Rotation, mirroring, and scaling transformations
10
- * - Camera system with position, scale, and rotation
11
- * - Multiple canvas support (main, WebGL, work canvases)
12
- * - Gradient fills and outlined shapes
13
- * - Image manipulation and color tinting
14
- *
15
- * Rendering Architecture:
16
- * - glCanvas: WebGL canvas for accelerated sprite batch rendering
17
- * - mainCanvas: Canvas2D overlay for text, UI, and custom drawing
18
- * - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter
19
- *
20
- * @namespace Draw
21
- */
22
-
23
- 'use strict';
24
-
25
- /** The primary 2D canvas visible to the user
26
- * @type {HTMLCanvasElement}
27
- * @memberof Draw */
28
- let mainCanvas;
29
-
30
- /** 2d context for mainCanvas
31
- * @type {CanvasRenderingContext2D}
32
- * @memberof Draw */
33
- let mainContext;
34
-
35
- /** The default 2d context to use for drawing, usually mainContext
36
- * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
37
- * @memberof Draw */
38
- let drawContext;
39
-
40
- /** Offscreen canvas that can be used for image processing
41
- * @type {OffscreenCanvas}
42
- * @memberof Draw */
43
- let workCanvas;
44
-
45
- /** Offscreen canvas that can be used for image processing
46
- * @type {OffscreenCanvasRenderingContext2D}
47
- * @memberof Draw */
48
- let workContext;
49
-
50
- /** Offscreen canvas with willReadFrequently that can be used for image processing
51
- * @type {OffscreenCanvas}
52
- * @memberof Draw */
53
- let workReadCanvas;
54
-
55
- /** Offscreen canvas with willReadFrequently that can be used for image processing
56
- * @type {OffscreenCanvasRenderingContext2D}
57
- * @memberof Draw */
58
- let workReadContext;
59
-
60
- /** Extra canvas to composite behind the engine canvases when combining canvases
61
- * Set by plugins that render to their own canvas below the LittleJS canvases
62
- * @type {HTMLCanvasElement}
63
- * @memberof Draw */
64
- let backgroundCanvas;
65
-
66
- /** The size of the main canvas (and other secondary canvases)
67
- * @type {Vector2}
68
- * @memberof Draw */
69
- let mainCanvasSize = vec2();
70
-
71
- /** Array containing texture info for batch rendering system
72
- * @type {Array<TextureInfo>}
73
- * @memberof Draw */
74
- let textureInfos = [];
75
-
76
- /** Keeps track of how many draw calls there were each frame for debugging
77
- * @type {number}
78
- * @memberof Draw */
79
- let drawCount;
80
-
81
- /** Keeps track of how many primitives were drawn each frame for debugging
82
- * A single draw call can render many primitives (e.g. a WebGL sprite batch).
83
- * @type {number}
84
- * @memberof Draw */
85
- let primitiveCount;
86
-
87
- // internal predicates for tint short-circuiting in canvas2D draw paths
88
- // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
89
- // isBlack includes alpha so additive colors that only contribute alpha are not skipped
90
- /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
91
- /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
92
-
93
- ///////////////////////////////////////////////////////////////////////////////
94
-
95
- /**
96
- * Create a tile info object using a grid based system
97
- * - This can take vecs or floats for easier use and conversion
98
- * - If an index is passed in, the tile size and index will determine the position
99
- * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
100
- * @param {Vector2|number} [size] - Size of tile in pixels
101
- * @param {TextureInfo|number} [texture] - Texture index or info to use
102
- * @param {number} [padding] - How many pixels padding around tiles
103
- * @param {number} [bleed] - How many pixels smaller to draw tiles
104
- * @return {TileInfo}
105
- * @example
106
- * tile(2) // a tile at index 2 using the default tile size of 16
107
- * tile(5, 8) // a tile at index 5 using a tile size of 8
108
- * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
109
- * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
110
- * @memberof Draw */
111
- function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
112
- {
113
- ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
114
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
115
- ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
116
- ASSERT(isNumber(padding), 'padding must be a number');
117
-
118
- if (headlessMode) return new TileInfo;
119
-
120
- if (typeof size === 'number')
121
- {
122
- // if size is a number, make it a vector
123
- ASSERT(size > 0);
124
- size = new Vector2(size, size);
125
- }
126
-
127
- // create tile info object
128
- const textureInfo = typeof texture === 'number' ?
129
- textureInfos[texture] : texture;
130
- ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
131
- ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
132
-
133
- // get the position of the tile
134
- const sizePaddedX = size.x + padding*2;
135
- const sizePaddedY = size.y + padding*2;
136
- let x, y;
137
- if (typeof index === 'number')
138
- {
139
- const cols = textureInfo.size.x / sizePaddedX |0;
140
- x = index % cols;
141
- y = index / cols |0;
142
- }
143
- else
144
- {
145
- x = index.x;
146
- y = index.y;
147
- }
148
- const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding);
149
- return new TileInfo(pos, size, textureInfo, padding, bleed);
150
- }
151
-
152
- /**
153
- * Tile Info - Stores info about how to draw a tile
154
- * @memberof Draw
155
- */
156
- class TileInfo
157
- {
158
- /** Create a tile info object
159
- * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
160
- * @param {Vector2} [size] - Size of tile in pixels
161
- * @param {TextureInfo} [textureInfo] - Texture info to use
162
- * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
163
- * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
164
- * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
165
- */
166
- constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
167
- {
168
- /** @property {Vector2} - Top left corner of tile in pixels */
169
- this.pos = pos.copy();
170
- /** @property {Vector2} - Size of tile in pixels */
171
- this.size = size.copy();
172
- /** @property {number} - How many pixels padding around tiles */
173
- this.padding = padding;
174
- /** @property {TextureInfo} - The texture info for this tile */
175
- this.textureInfo = textureInfo;
176
- /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
177
- this.bleed = bleed;
178
- /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
179
- this.columns = columns;
180
- }
181
-
182
- /** Returns a copy of this tile offset by a vector
183
- * @param {Vector2} offset - Offset to apply in pixels
184
- * @return {TileInfo}
185
- */
186
- offset(offset)
187
- { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
188
-
189
- /** Returns a copy of this tile offset by a number of animation frames
190
- * Frames wrap down to the next row if columns is set
191
- * @param {number} frame - Offset to apply in animation frames
192
- * @return {TileInfo}
193
- */
194
- frame(frame)
195
- {
196
- ASSERT(typeof frame === 'number');
197
- const w = this.size.x + this.padding*2;
198
- const h = this.size.y + this.padding*2;
199
- const x = (this.columns ? frame % this.columns : frame) * w;
200
- const y = (this.columns ? frame / this.columns | 0 : 0) * h;
201
- ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
202
- ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
203
- return this.offset(new Vector2(x, y));
204
- }
205
-
206
- /** Set how many frames per row this tile uses, so frame() can wrap
207
- * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
208
- * @return {TileInfo}
209
- */
210
- setColumns(columns=0)
211
- {
212
- ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
213
- this.columns = columns;
214
- return this;
215
- }
216
-
217
- /**
218
- * Returns a tile info for an index using this tile as reference
219
- * @param {Vector2|number} [index=0]
220
- * @return {TileInfo}
221
- */
222
- index(index)
223
- { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
224
-
225
- /**
226
- * Set this tile to use a full image in a texture info
227
- * @param {TextureInfo} [textureInfo]
228
- * @return {TileInfo}
229
- */
230
- setFullImage(textureInfo=this.textureInfo)
231
- {
232
- this.textureInfo = textureInfo;
233
- this.pos = new Vector2;
234
- this.size = textureInfo.size.copy();
235
- this.bleed = this.padding = this.columns = 0;
236
- return this;
237
- }
238
- }
239
-
240
- /**
241
- * Tile Info - Stores info about each texture
242
- * @memberof Draw
243
- */
244
- class TextureInfo
245
- {
246
- /**
247
- * Create a TextureInfo, called automatically by the engine
248
- * @param {HTMLImageElement|OffscreenCanvas} image
249
- * @param {boolean} [useWebGL] - Should use WebGL if available?
250
- * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
251
- */
252
- constructor(image, useWebGL=true, wrap=false)
253
- {
254
- /** @property {HTMLImageElement|OffscreenCanvas} - image source */
255
- this.image = image;
256
- /** @property {Vector2} - size of the image */
257
- this.size = image ? vec2(image.width, image.height) : vec2();
258
- /** @property {Vector2} - inverse of the size, cached for rendering */
259
- this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
260
- /** @property {WebGLTexture} - WebGL texture */
261
- this.glTexture = undefined;
262
- /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
263
- this.wrap = wrap;
264
- useWebGL && this.createWebGLTexture();
265
- }
266
-
267
- /** Creates the WebGL texture, updates if already created */
268
- createWebGLTexture() { glRegisterTextureInfo(this); }
269
-
270
- /** Destroys the WebGL texture */
271
- destroyWebGLTexture() { glUnregisterTextureInfo(this); }
272
-
273
- /** Check if the texture is webgl enabled
274
- * @return {boolean} */
275
- hasWebGL() { return !!this.glTexture; }
276
-
277
- /** Set the wrap mode for this texture
278
- * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
279
- setWrap(wrap=true)
280
- {
281
- this.wrap = wrap;
282
- glSetTextureWrap(this.glTexture, wrap);
283
- }
284
- }
285
-
286
- ///////////////////////////////////////////////////////////////////////////////
287
- // Drawing functions
288
-
289
- /** Draw textured tile centered in world space
290
- * @param {Vector2} pos - Center of the tile in world space
291
- * @param {Vector2} [size=vec2(1)] - Size of the tile in world space
292
- * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
293
- * @param {Color} [color=WHITE] - Color to modulate with
294
- * @param {number} [angle] - Angle to rotate by
295
- * @param {boolean} [mirror] - Is image flipped along the Y axis?
296
- * @param {Color} [additiveColor] - Additive color to be applied if any
297
- * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
298
- * @param {boolean} [screenSpace=false] - Are the pos and size are in screen space?
299
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
300
- * @memberof Draw */
301
- function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
302
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
303
- {
304
- ASSERT(isVector2(pos), 'pos must be a vec2');
305
- ASSERT(isVector2(size), 'size must be a vec2');
306
- ASSERT(isColor(color), 'color is invalid');
307
- ASSERT(isNumber(angle), 'angle must be a number');
308
- ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
309
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
310
-
311
- const textureInfo = tileInfo?.textureInfo;
312
- const bleed = tileInfo?.bleed ?? 0;
313
- if (useWebGL && glEnable)
314
- {
315
- ASSERT(!!glContext, 'WebGL is not enabled!');
316
- if (screenSpace)
317
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
318
- if (textureInfo)
319
- {
320
- // calculate uvs and render
321
- const sizeInverse = textureInfo.sizeInverse;
322
- const x = tileInfo.pos.x * sizeInverse.x;
323
- const y = tileInfo.pos.y * sizeInverse.y;
324
- const w = tileInfo.size.x * sizeInverse.x;
325
- const h = tileInfo.size.y * sizeInverse.y;
326
- glSetTexture(textureInfo.glTexture);
327
- if (bleed)
328
- {
329
- const bleedX = sizeInverse.x*bleed;
330
- const bleedY = sizeInverse.y*bleed;
331
- glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
332
- x + bleedX, y + bleedY,
333
- x - bleedX + w, y - bleedY + h,
334
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
335
- }
336
- else
337
- {
338
- glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
339
- x, y, x + w, y + h,
340
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
341
- }
342
- }
343
- else
344
- {
345
- // untextured: fold color+additive to match the Canvas2D path's
346
- // color.add(additiveColor) on line ~337.
347
- const combined = additiveColor ? color.add(additiveColor) : color;
348
- glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
349
- }
350
- }
351
- else
352
- {
353
- // normal canvas 2D rendering method (slower)
354
- ++drawCount;
355
- ++primitiveCount;
356
- drawCanvas2D(pos, size, angle, mirror, (context)=>
357
- {
358
- if (textureInfo)
359
- {
360
- // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
361
- context.scale(1, -1);
362
- // calculate uvs and render
363
- const x = tileInfo.pos.x, y = tileInfo.pos.y;
364
- const w = tileInfo.size.x, h = tileInfo.size.y;
365
- drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed);
366
- }
367
- else
368
- {
369
- // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
370
- const c = additiveColor ? color.add(additiveColor) : color;
371
- context.fillStyle = c.toString();
372
- context.fillRect(-.5, -.5, 1, 1);
373
- }
374
- }, screenSpace, context);
375
- }
376
- }
377
-
378
- /** Draw colored rect centered on pos
379
- * @param {Vector2} pos
380
- * @param {Vector2} [size=vec2(1)]
381
- * @param {Color} [color=WHITE]
382
- * @param {number} [angle]
383
- * @param {boolean} [useWebGL=glEnable]
384
- * @param {boolean} [screenSpace]
385
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
386
- * @memberof Draw */
387
- function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
388
- {
389
- drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context);
390
- }
391
-
392
- /** Draw a rect centered on pos with a gradient from top to bottom
393
- * @param {Vector2} pos
394
- * @param {Vector2} [size=vec2(1)]
395
- * @param {Color} [colorTop=WHITE]
396
- * @param {Color} [colorBottom=CLEAR_WHITE]
397
- * @param {number} [angle]
398
- * @param {boolean} [useWebGL=glEnable]
399
- * @param {boolean} [screenSpace]
400
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
401
- * @memberof Draw */
402
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
403
- {
404
- ASSERT(isVector2(pos), 'pos must be a vec2');
405
- ASSERT(isVector2(size), 'size must be a vec2');
406
- ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
407
- ASSERT(isNumber(angle), 'angle must be a number');
408
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
409
-
410
- if (useWebGL && glEnable)
411
- {
412
- ASSERT(!!glContext, 'WebGL is not enabled!');
413
- if (screenSpace)
414
- {
415
- // convert to world space
416
- pos = screenToWorld(pos);
417
- size = size.scale(1/cameraScale);
418
- angle += cameraAngle;
419
- }
420
- // build 4 corner points for the rectangle
421
- const points = [], colors = [];
422
- const halfSizeX = size.x/2, halfSizeY = size.y/2;
423
- const colorTopInt = colorTop.rgbaInt();
424
- const colorBottomInt = colorBottom.rgbaInt();
425
- const c = cos(-angle), s = sin(-angle);
426
- for (let i=4; i--;)
427
- {
428
- const x = i & 1 ? halfSizeX : -halfSizeX;
429
- const y = i & 2 ? halfSizeY : -halfSizeY;
430
- const rx = x * c - y * s;
431
- const ry = x * s + y * c;
432
- const color = i & 2 ? colorTopInt : colorBottomInt;
433
- points.push(vec2(pos.x + rx, pos.y + ry));
434
- colors.push(color);
435
- }
436
- glDrawColoredPoints(points, colors);
437
- }
438
- else
439
- {
440
- // normal canvas 2D rendering method (slower)
441
- ++drawCount;
442
- ++primitiveCount;
443
- drawCanvas2D(pos, size, angle, false, (context)=>
444
- {
445
- // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
446
- const gradient = context.createLinearGradient(0, .5, 0, -.5);
447
- gradient.addColorStop(0, colorTop.toString());
448
- gradient.addColorStop(1, colorBottom.toString());
449
- context.fillStyle = gradient;
450
- context.fillRect(-.5, -.5, 1, 1);
451
- }, screenSpace, context);
452
- }
453
- }
454
-
455
- /** Draw a texture tiled (wrapped) across a rectangle in world space.
456
- * Useful for backgrounds, repeating patterns, and seamless fills.
457
- * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
458
- * @param {Vector2} pos - Center of the rect in world space
459
- * @param {Vector2} size - Size of the rect in world space
460
- * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
461
- * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
462
- * @param {Color} [color=WHITE] - Color to modulate with
463
- * @param {number} [angle=0] - Angle to rotate by
464
- * @param {Color} [additiveColor] - Additive color to be applied if any
465
- * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
466
- * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
467
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
468
- * @memberof Draw */
469
- function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
470
- angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
471
- {
472
- ASSERT(isVector2(pos), 'pos must be a vec2');
473
- ASSERT(isVector2(size), 'size must be a vec2');
474
- ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
475
- ASSERT(isColor(color), 'color is invalid');
476
- ASSERT(isNumber(angle), 'angle must be a number');
477
- ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
478
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
479
- ASSERT(!(texture instanceof TileInfo),
480
- 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
481
-
482
- // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
483
- if (headlessMode) return;
484
-
485
- // resolve texture argument: TextureInfo or index
486
- const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
487
- ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
488
- ASSERT(textureInfo.size.x > 0, 'texture not loaded');
489
- ASSERT(textureInfo.wrap,
490
- 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
491
-
492
- if (useWebGL && glEnable)
493
- {
494
- ASSERT(!!glContext, 'WebGL is not enabled!');
495
- if (screenSpace)
496
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
497
- glSetTexture(textureInfo.glTexture);
498
- glDraw(pos.x, pos.y, size.x, size.y, angle,
499
- 0, 0, wrapCount.x, wrapCount.y,
500
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
501
- return;
502
- }
503
-
504
- // Canvas2D path — increment counts here (WebGL counts via glFlush)
505
- ++drawCount;
506
- ++primitiveCount;
507
-
508
- if (!screenSpace)
509
- {
510
- pos = worldToScreen(pos);
511
- size = size.scale(cameraScale);
512
- angle -= cameraAngle;
513
- }
514
-
515
- // pick image source: raw, or tinted bake. Match drawImageColor's
516
- // "no tint needed" predicate so behavior stays consistent.
517
- const noTint = !canvasColorTiles ||
518
- (additiveColor
519
- ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
520
- : isWhite(color));
521
- // alpha is baked into pixels by bakeTintedImage's additive branch;
522
- // in that case globalAlpha must NOT also apply color.a
523
- const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
524
- const source = noTint
525
- ? textureInfo.image
526
- : bakeTintedImage(textureInfo.image, color, additiveColor);
527
-
528
- context = context || drawContext;
529
- context.save();
530
- context.translate(pos.x + .5, pos.y + .5);
531
- context.rotate(angle);
532
- context.globalAlpha = alphaBaked ? 1 : color.a;
533
-
534
- const pattern = context.createPattern(source, 'repeat');
535
- // map pattern-source pixels into user space so the rect contains
536
- // wrapCount.x × wrapCount.y repeats
537
- const m = new DOMMatrix()
538
- .translate(-size.x/2, -size.y/2)
539
- .scale(size.x / (wrapCount.x * source.width),
540
- size.y / (wrapCount.y * source.height));
541
- pattern.setTransform(m);
542
- context.fillStyle = pattern;
543
- context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
544
- context.globalAlpha = 1;
545
- context.restore();
546
- }
547
-
548
- /** Draw connected lines between a series of points
549
- * @param {Array<Vector2>} points
550
- * @param {number} [width]
551
- * @param {Color} [color=WHITE]
552
- * @param {boolean} [wrap] - Should the last point connect to the first?
553
- * @param {Vector2} [pos=vec2()] - Offset to apply
554
- * @param {number} [angle] - Angle to rotate by
555
- * @param {boolean} [useWebGL=glEnable]
556
- * @param {boolean} [screenSpace]
557
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
558
- * @memberof Draw */
559
- function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
560
- {
561
- ASSERT(isArray(points), 'points must be an array');
562
- ASSERT(isNumber(width), 'width must be a number');
563
- ASSERT(isColor(color), 'color is invalid');
564
- ASSERT(isVector2(pos), 'pos must be a vec2');
565
- ASSERT(isNumber(angle), 'angle must be a number');
566
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
567
-
568
- if (useWebGL && glEnable)
569
- {
570
- ASSERT(!!glContext, 'WebGL is not enabled!');
571
- let size = vec2(1);
572
- if (screenSpace)
573
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
574
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
575
- }
576
- else
577
- {
578
- // normal canvas 2D rendering method (slower)
579
- ++drawCount;
580
- ++primitiveCount;
581
- drawCanvas2D(pos, vec2(1), angle, false, (context)=>
582
- {
583
- context.strokeStyle = color.toString();
584
- context.lineWidth = width;
585
- context.beginPath();
586
- for (let i=0; i<points.length; ++i)
587
- {
588
- const point = points[i];
589
- context.lineTo(point.x, point.y);
590
- }
591
- wrap && context.closePath();
592
- context.stroke();
593
- }, screenSpace, context);
594
- }
595
- }
596
-
597
- /** Draw colored line between two points
598
- * @param {Vector2} posA
599
- * @param {Vector2} posB
600
- * @param {number} [width]
601
- * @param {Color} [color=WHITE]
602
- * @param {Vector2} [pos=vec2()] - Offset to apply
603
- * @param {number} [angle] - Angle to rotate by
604
- * @param {boolean} [useWebGL=glEnable]
605
- * @param {boolean} [screenSpace]
606
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
607
- * @memberof Draw */
608
- function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
609
- {
610
- const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
611
- const size = vec2(width, halfDelta.length()*2);
612
- pos = pos.add(posA.add(halfDelta));
613
- if (screenSpace)
614
- halfDelta.y *= -1; // flip angle Y if screen space
615
- angle += halfDelta.angle();
616
- drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
617
- }
618
-
619
- /** Draw colored regular polygon using passed in number of sides
620
- * @param {Vector2} pos
621
- * @param {Vector2} [size=vec2(1)]
622
- * @param {number} [sides]
623
- * @param {Color} [color=WHITE]
624
- * @param {number} [lineWidth]
625
- * @param {Color} [lineColor=BLACK]
626
- * @param {number} [angle]
627
- * @param {boolean} [useWebGL=glEnable]
628
- * @param {boolean} [screenSpace]
629
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
630
- * @memberof Draw */
631
- function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
632
- {
633
- ASSERT(isVector2(size), 'size must be a vec2');
634
- ASSERT(isNumber(sides), 'sides must be a number');
635
-
636
- // build regular polygon points
637
- const points = [];
638
- const sizeX = size.x/2, sizeY = size.y/2;
639
- for (let i=sides; i--;)
640
- {
641
- const a = (i/sides)*PI*2;
642
- points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
643
- }
644
- drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
645
- }
646
-
647
- /** Draw colored polygon using passed in points
648
- * @param {Array<Vector2>} points - Array of Vector2 points
649
- * @param {Color} [color=WHITE]
650
- * @param {number} [lineWidth]
651
- * @param {Color} [lineColor=BLACK]
652
- * @param {Vector2} [pos=vec2()] - Offset to apply
653
- * @param {number} [angle] - Angle to rotate by
654
- * @param {boolean} [useWebGL=glEnable]
655
- * @param {boolean} [screenSpace]
656
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
657
- * @memberof Draw */
658
- function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
659
- {
660
- ASSERT(isVector2(pos), 'pos must be a vec2');
661
- ASSERT(isArray(points), 'points must be an array');
662
- ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
663
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
664
- ASSERT(isNumber(angle), 'angle must be a number');
665
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
666
-
667
- if (useWebGL && glEnable)
668
- {
669
- ASSERT(!!glContext, 'WebGL is not enabled!');
670
- let size = vec2(1);
671
- if (screenSpace)
672
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
673
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
674
- if (lineWidth > 0)
675
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
676
- }
677
- else
678
- {
679
- drawCanvas2D(pos, vec2(1), angle, false, context=>
680
- {
681
- context.fillStyle = color.toString();
682
- context.beginPath();
683
- for (const point of points)
684
- context.lineTo(point.x, point.y);
685
- context.closePath();
686
- context.fill();
687
- if (lineWidth)
688
- {
689
- context.strokeStyle = lineColor.toString();
690
- context.lineWidth = lineWidth;
691
- context.stroke();
692
- }
693
- }, screenSpace, context);
694
- }
695
- }
696
-
697
- /** Draw colored ellipse using passed in point
698
- * @param {Vector2} pos
699
- * @param {Vector2} [size=vec2(1)] - Width and height diameter
700
- * @param {Color} [color=WHITE]
701
- * @param {number} [angle]
702
- * @param {number} [lineWidth]
703
- * @param {Color} [lineColor=BLACK]
704
- * @param {boolean} [useWebGL=glEnable]
705
- * @param {boolean} [screenSpace]
706
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
707
- * @memberof Draw */
708
- function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
709
- {
710
- ASSERT(isVector2(pos), 'pos must be a vec2');
711
- ASSERT(isVector2(size), 'size must be a vec2');
712
- ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
713
- ASSERT(isNumber(angle), 'angle must be a number');
714
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
715
- ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
716
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
717
-
718
- // clamp line width to prevent artifacts
719
- lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
720
-
721
- if (useWebGL && glEnable)
722
- {
723
- // draw as a regular polygon
724
- const sides = glCircleSides;
725
- drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
726
- }
727
- else
728
- {
729
- drawCanvas2D(pos, vec2(1), angle, false, context=>
730
- {
731
- context.fillStyle = color.toString();
732
- context.beginPath();
733
- context.ellipse(0, 0, size.x/2, size.y/2, 0, 0, 9);
734
- context.fill();
735
- if (lineWidth)
736
- {
737
- context.strokeStyle = lineColor.toString();
738
- context.lineWidth = lineWidth;
739
- context.stroke();
740
- }
741
- }, screenSpace, context);
742
- }
743
- }
744
-
745
- /** Draw colored circle using passed in point
746
- * @param {Vector2} pos
747
- * @param {number} [size=1] - Diameter
748
- * @param {Color} [color=WHITE]
749
- * @param {number} [lineWidth=0]
750
- * @param {Color} [lineColor=BLACK]
751
- * @param {boolean} [useWebGL=glEnable]
752
- * @param {boolean} [screenSpace]
753
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
754
- * @memberof Draw */
755
- function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
756
- {
757
- ASSERT(isNumber(size), 'size must be a number');
758
- drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
759
- }
760
-
761
- /** Draw an ellipse filled with a radial gradient from the center to the rim
762
- * - Best when batched with other untextured polys
763
- * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
764
- * - Stacking gradients at the exact same position may show a faint vertical artifact
765
- * @param {Vector2} pos
766
- * @param {Vector2} [size=vec2(1)] - Width and height diameter
767
- * @param {Color} [colorInner=WHITE]
768
- * @param {Color} [colorOuter=CLEAR_WHITE]
769
- * @param {number} [angle]
770
- * @param {boolean} [useWebGL=glEnable]
771
- * @param {boolean} [screenSpace]
772
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
773
- * @memberof Draw */
774
- let drawEllipseGradientOffset = 0;
775
- function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
776
- {
777
- ASSERT(isVector2(pos), 'pos must be a vec2');
778
- ASSERT(isVector2(size), 'size must be a vec2');
779
- ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
780
- ASSERT(isNumber(angle), 'angle must be a number');
781
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
782
-
783
- if (headlessMode) return;
784
-
785
- if (useWebGL && glEnable)
786
- {
787
- ASSERT(!!glContext, 'WebGL is not enabled!');
788
- if (screenSpace)
789
- {
790
- // convert to world space
791
- pos = screenToWorld(pos);
792
- size = size.scale(1/cameraScale);
793
- angle += cameraAngle;
794
- }
795
- // fan as tristrip; rotate the boundary vertex by one slice per call
796
- // so back-to-back gradients at the same position have their hole
797
- // (from gpu edge-rule on the boundary line-degen) at different rim
798
- // verts and don't visibly stack
799
- const sides = glCircleSides;
800
- const radiusX = size.x/2, radiusY = size.y/2;
801
- const innerInt = colorInner.rgbaInt();
802
- const outerInt = colorOuter.rgbaInt();
803
- const offset = drawEllipseGradientOffset++;
804
- const c = cos(-angle), s = sin(-angle);
805
- const rim = (a) =>
806
- {
807
- const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
808
- return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
809
- };
810
- const startA = (offset%sides)/sides*PI*2;
811
- const points = [rim(startA)];
812
- const colors = [outerInt];
813
- for (let i=sides; i--;)
814
- {
815
- const a = ((i+offset)%sides)/sides*PI*2;
816
- points.push(pos);
817
- colors.push(innerInt);
818
- points.push(rim(a));
819
- colors.push(outerInt);
820
- }
821
- glDrawColoredPoints(points, colors);
822
- }
823
- else
824
- {
825
- // normal canvas 2D rendering method (slower)
826
- ++drawCount;
827
- ++primitiveCount;
828
- drawCanvas2D(pos, size, angle, false, (context)=>
829
- {
830
- const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
831
- gradient.addColorStop(0, colorInner.toString());
832
- gradient.addColorStop(1, colorOuter.toString());
833
- context.fillStyle = gradient;
834
- context.beginPath();
835
- context.ellipse(0, 0, .5, .5, 0, 0, 9);
836
- context.fill();
837
- }, screenSpace, context);
838
- }
839
- }
840
-
841
- /** Draw a circle filled with a radial gradient from the center to the rim
842
- * - Best when batched with other untextured polys
843
- * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
844
- * - Stacking gradients at the exact same position may show a faint vertical artifact
845
- * @param {Vector2} pos
846
- * @param {number} [size=1] - Diameter
847
- * @param {Color} [colorInner=WHITE]
848
- * @param {Color} [colorOuter=CLEAR_WHITE]
849
- * @param {boolean} [useWebGL=glEnable]
850
- * @param {boolean} [screenSpace]
851
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
852
- * @memberof Draw */
853
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
854
- {
855
- ASSERT(isNumber(size), 'size must be a number');
856
- drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
857
- }
858
-
859
- /**
860
- * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
861
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
862
- * @memberof Draw
863
- */
864
-
865
- /** Draw directly to a 2d canvas context in world space.
866
- * The Y axis is flipped so world-Y-up coordinates render right-side up
867
- * (matches the WebGL path). Callers whose drawing depends on Y direction
868
- * (e.g. linear gradients) should flip their own Y endpoints accordingly.
869
- * @param {Vector2} pos
870
- * @param {Vector2} size
871
- * @param {number} angle
872
- * @param {boolean} [mirror]
873
- * @param {Canvas2DDrawFunction} [drawFunction]
874
- * @param {boolean} [screenSpace=false]
875
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
876
- * @memberof Draw */
877
- function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=false, context=drawContext)
878
- {
879
- ASSERT(isVector2(pos), 'pos must be a vec2');
880
- ASSERT(isVector2(size), 'size must be a vec2');
881
- ASSERT(isNumber(angle), 'angle must be a number');
882
- ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
883
-
884
- if (!screenSpace)
885
- {
886
- pos = worldToScreen(pos);
887
- size = size.scale(cameraScale);
888
- angle -= cameraAngle;
889
- }
890
- context.save();
891
- context.translate(pos.x+.5, pos.y+.5);
892
- context.rotate(angle);
893
- context.scale(mirror ? -size.x : size.x, -size.y);
894
- drawFunction(context);
895
- context.restore();
896
- }
897
-
898
- ///////////////////////////////////////////////////////////////////////////////
899
- // Text Drawing Functions
900
-
901
- /** Draw text on main canvas in world space
902
- * Automatically splits new lines into rows
903
- * @param {string|number} text
904
- * @param {Vector2} pos
905
- * @param {number} [size]
906
- * @param {Color} [color=WHITE]
907
- * @param {number} [lineWidth]
908
- * @param {Color} [lineColor=BLACK]
909
- * @param {CanvasTextAlign} [textAlign='center']
910
- * @param {string} [font=fontDefault]
911
- * @param {string} [fontStyle]
912
- * @param {number} [maxWidth]
913
- * @param {number} [angle]
914
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
915
- * @memberof Draw */
916
- function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
917
- {
918
- // convert to screen space
919
- pos = worldToScreen(pos);
920
- size *= cameraScale;
921
- lineWidth *= cameraScale;
922
- angle -= cameraAngle;
923
- angle *= -1;
924
-
925
- drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
926
- }
927
-
928
- /** Draw text in screen space
929
- * Automatically splits new lines into rows
930
- * @param {string|number} text
931
- * @param {Vector2} pos
932
- * @param {number} size
933
- * @param {Color} [color=WHITE]
934
- * @param {number} [lineWidth]
935
- * @param {Color} [lineColor=BLACK]
936
- * @param {CanvasTextAlign} [textAlign]
937
- * @param {string} [font=fontDefault]
938
- * @param {string} [fontStyle]
939
- * @param {number} [maxWidth]
940
- * @param {number} [angle]
941
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
942
- * @memberof Draw */
943
- function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
944
- {
945
- ASSERT(isStringLike(text), 'text must be a string');
946
- ASSERT(isVector2(pos), 'pos must be a vec2');
947
- ASSERT(isNumber(size), 'size must be a number');
948
- ASSERT(isColor(color), 'color must be a color');
949
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
950
- ASSERT(isColor(lineColor), 'lineColor must be a color');
951
- ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
952
- ASSERT(isStringLike(font), 'font must be a string');
953
- ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
954
- ASSERT(isNumber(angle), 'angle must be a number');
955
-
956
- const lines = (text+'').split('\n');
957
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
958
- // save before style mutations so caller's context state is preserved
959
- context.save();
960
- context.fillStyle = color.toString();
961
- context.strokeStyle = lineColor.toString();
962
- context.lineWidth = lineWidth;
963
- context.textAlign = textAlign;
964
- context.font = fontStyle + ' ' + size + 'px '+ font;
965
- context.textBaseline = 'middle';
966
- context.translate(pos.x, posY);
967
- context.rotate(-angle);
968
- let yOffset = 0;
969
- lines.forEach(line=>
970
- {
971
- lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
972
- context.fillText(line, 0, yOffset, maxWidth);
973
- yOffset += size;
974
- });
975
- context.restore();
976
- }
977
-
978
- ///////////////////////////////////////////////////////////////////////////////
979
- // Drawing utilities
980
-
981
- /** Load a texture at a specific index
982
- * @param {number} textureIndex - Index to store the texture at
983
- * @param {string} [src] - Image source path
984
- * @return {Promise} Promise that resolves when texture is loaded
985
- * @memberof Draw */
986
- async function loadTexture(textureIndex, src)
987
- {
988
- ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
989
- ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
990
- ASSERT(!src || isStringLike(src), 'image src must be a string');
991
-
992
- const image = new Image;
993
- if (src)
994
- {
995
- await new Promise(resolve =>
996
- {
997
- image.onerror = image.onload = resolve;
998
- image.crossOrigin = 'anonymous';
999
- image.src = src;
1000
- });
1001
- }
1002
-
1003
- textureInfos[textureIndex] = new TextureInfo(image);
1004
- }
1005
-
1006
- /** Convert from screen to world space coordinates
1007
- * @param {Vector2} screenPos
1008
- * @return {Vector2}
1009
- * @memberof Draw */
1010
- function screenToWorld(screenPos)
1011
- {
1012
- ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1013
-
1014
- let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
1015
- let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
1016
- if (cameraAngle)
1017
- {
1018
- // apply camera rotation
1019
- const c = cos(-cameraAngle), s = sin(-cameraAngle);
1020
- const xr = x * c - y * s, yr = x * s + y * c;
1021
- x = xr; y = yr;
1022
- }
1023
- return new Vector2(x + cameraPos.x, y + cameraPos.y);
1024
- }
1025
-
1026
- /** Convert from world to screen space coordinates
1027
- * @param {Vector2} worldPos
1028
- * @return {Vector2}
1029
- * @memberof Draw */
1030
- function worldToScreen(worldPos)
1031
- {
1032
- ASSERT(isVector2(worldPos), 'worldPos must be a vec2');
1033
-
1034
- let x = worldPos.x - cameraPos.x;
1035
- let y = worldPos.y - cameraPos.y;
1036
- if (cameraAngle)
1037
- {
1038
- // apply inverse camera rotation
1039
- const c = cos(cameraAngle), s = sin(cameraAngle);
1040
- const xr = x * c - y * s, yr = x * s + y * c;
1041
- x = xr; y = yr;
1042
- }
1043
- return new Vector2
1044
- (
1045
- x * cameraScale + mainCanvasSize.x/2 - .5,
1046
- y * -cameraScale + mainCanvasSize.y/2 - .5
1047
- );
1048
- }
1049
-
1050
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1051
- * @param {Vector2} screenDelta
1052
- * @return {Vector2}
1053
- * @memberof Draw */
1054
- function screenToWorldDelta(screenDelta)
1055
- {
1056
- ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2');
1057
-
1058
- let x = screenDelta.x / cameraScale;
1059
- let y = screenDelta.y / -cameraScale;
1060
- if (cameraAngle)
1061
- {
1062
- // apply camera rotation
1063
- const c = cos(-cameraAngle), s = sin(-cameraAngle);
1064
- const xr = x * c - y * s, yr = x * s + y * c;
1065
- x = xr; y = yr;
1066
- }
1067
- return new Vector2(x, y);
1068
- }
1069
-
1070
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1071
- * @param {Vector2} worldDelta
1072
- * @return {Vector2}
1073
- * @memberof Draw */
1074
- function worldToScreenDelta(worldDelta)
1075
- {
1076
- ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2');
1077
-
1078
- let x = worldDelta.x;
1079
- let y = worldDelta.y;
1080
- if (cameraAngle)
1081
- {
1082
- // apply inverse camera rotation
1083
- const c = cos(cameraAngle), s = sin(cameraAngle);
1084
- const xr = x * c - y * s, yr = x * s + y * c;
1085
- x = xr; y = yr;
1086
- }
1087
- return new Vector2(x * cameraScale, y * -cameraScale);
1088
- }
1089
-
1090
- /** Convert screen space transform to world space
1091
- * @param {Vector2} screenPos
1092
- * @param {Vector2} screenSize
1093
- * @param {number} [screenAngle]
1094
- * @return {[Vector2, Vector2, number]} - [pos, size, angle]
1095
- * @memberof Draw */
1096
- function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1097
- {
1098
- ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1099
- ASSERT(isVector2(screenSize), 'screenSize must be a vec2');
1100
- ASSERT(isNumber(screenAngle), 'screenAngle must be a number');
1101
-
1102
- return [
1103
- screenToWorld(screenPos),
1104
- screenSize.scale(1/cameraScale),
1105
- screenAngle + cameraAngle
1106
- ];
1107
- }
1108
-
1109
- /** Get the size of the camera window in world space
1110
- * @return {Vector2}
1111
- * @memberof Draw */
1112
- function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1113
-
1114
- /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1115
- * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1116
- * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
1117
- * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
1118
- * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
1119
- * or an object with any of {top, right, bottom, left}
1120
- * @param {Vector2} center - Center of the rectangle in world space
1121
- * @param {Vector2} size - Size of the rectangle in world space
1122
- * @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
1123
- * @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1124
- * @return {number} - The new camera scale
1125
- * @memberof Draw */
1126
- function cameraFit(center, size, worldMargin, screenInset)
1127
- {
1128
- ASSERT(isVector2(center), 'center must be a vec2');
1129
- ASSERT(isVector2(size), 'size must be a vec2');
1130
-
1131
- // pad the content
1132
- const margin = padSides(worldMargin);
1133
- const inset = padSides(screenInset);
1134
- const worldW = size.x + margin.left + margin.right;
1135
- const worldH = size.y + margin.top + margin.bottom;
1136
- const viewW = mainCanvasSize.x - inset.left - inset.right;
1137
- const viewH = mainCanvasSize.y - inset.top - inset.bottom;
1138
-
1139
- // bail on a degenerate rect or viewport rather than NaN the camera
1140
- if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1141
- return cameraScale;
1142
-
1143
- // scale to fit the padded content
1144
- cameraScale = min(viewW / worldW, viewH / worldH);
1145
-
1146
- // calculate offset vectors
1147
- const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
1148
- const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
1149
-
1150
- // apply the offsets and return camera scale
1151
- cameraPos = center.add(marginVector).add(insetVector);
1152
- return cameraScale;
1153
-
1154
- function padSides(p)
1155
- {
1156
- // normalize a padding option to {top, right, bottom, left}
1157
- if (p === undefined || isNumber(p))
1158
- p = vec2(p);
1159
- if (isVector2(p))
1160
- return { top: p.y, right: p.x, bottom: p.y, left: p.x };
1161
- return {
1162
- top: p.top || 0,
1163
- right: p.right || 0,
1164
- bottom: p.bottom || 0,
1165
- left: p.left || 0,
1166
- };
1167
- }
1168
- }
1169
-
1170
- /** Check if a box, point, or circle is on screen with a circle test
1171
- * If size is a Vector2, uses the length as diameter
1172
- * This can be used to cull offscreen objects from render or update
1173
- * @param {Vector2} pos - world space position
1174
- * @param {Vector2|number} size - world space size or diameter
1175
- * @return {boolean}
1176
- * @memberof Draw */
1177
- function isOnScreen(pos, size=0)
1178
- {
1179
- ASSERT(isVector2(pos), 'pos must be a vec2');
1180
- ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
1181
-
1182
- // cameraScale of 0 collapses world coords; nothing is visible
1183
- if (!cameraScale) return false;
1184
-
1185
- // optimized circle on screen test
1186
- // pos = worldToScreen(pos);
1187
- let x = pos.x - cameraPos.x;
1188
- let y = pos.y - cameraPos.y;
1189
- if (cameraAngle)
1190
- {
1191
- // apply inverse camera rotation
1192
- const c = cos(cameraAngle), s = sin(cameraAngle);
1193
- const xr = x * c - y * s, yr = x * s + y * c;
1194
- x = xr; y = yr;
1195
- }
1196
- x *= cameraScale*2; y *= -cameraScale*2;
1197
-
1198
- if (size instanceof Vector2)
1199
- size = size.length(); // use length of vector as diameter
1200
- size *= cameraScale;
1201
-
1202
- // check against screen bounds
1203
- const w = mainCanvasSize.x, h = mainCanvasSize.y;
1204
- return x + size > -w && x - size < w &&
1205
- y + size > -h && y - size < h;
1206
- }
1207
-
1208
- /** Enable additive blending
1209
- * @param {boolean} [additive]
1210
- * @memberof Draw */
1211
- function setAdditiveBlendMode(additive=true)
1212
- {
1213
- glAdditive = additive;
1214
- drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1215
- }
1216
-
1217
- /** Set an extra canvas to composite behind the engine canvases when combining
1218
- * Plugins that insert their own canvas below the LittleJS canvases should set
1219
- * this so it appears in screenshots and video capture
1220
- * @param {HTMLCanvasElement} [canvas]
1221
- * @memberof Draw */
1222
- function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
1223
-
1224
- /** Combines LittleJS canvases onto the main canvas
1225
- * This is necessary for things like screenshots and video
1226
- * @memberof Draw */
1227
- function combineCanvases()
1228
- {
1229
- const w = mainCanvasSize.x, h = mainCanvasSize.y;
1230
- workCanvas.width = w;
1231
- workCanvas.height = h;
1232
- // remove background alpha explicit fillStyle so a previous caller
1233
- // leaving workContext.fillStyle transparent can't silently no-op this
1234
- workContext.fillStyle = '#000';
1235
- workContext.fillRect(0,0,w,h);
1236
- if (backgroundCanvas)
1237
- workContext.drawImage(backgroundCanvas, 0, 0, w, h);
1238
- glCopyToContext(workContext);
1239
- workContext.drawImage(mainCanvas, 0, 0);
1240
- mainContext.drawImage(workCanvas, 0, 0);
1241
- }
1242
-
1243
- // Internal: bake a color/additive-color tint into workReadCanvas at the
1244
- // image's native resolution. Returns the work canvas, suitable for
1245
- // passing to context.createPattern. Used by drawTextureWrapped's
1246
- // Canvas2D path. Caller is responsible for short-circuiting when no
1247
- // tint is needed (i.e. color is white and additiveColor is black/none).
1248
- function bakeTintedImage(image, color, additiveColor)
1249
- {
1250
- const w = image.width|0, h = image.height|0;
1251
- workReadCanvas.width = w;
1252
- workReadCanvas.height = h;
1253
- workReadContext.drawImage(image, 0, 0);
1254
-
1255
- const imageData = workReadContext.getImageData(0, 0, w, h);
1256
- const data = imageData.data;
1257
- if (additiveColor && !isBlack(additiveColor))
1258
- {
1259
- // multiply + additive (slower)
1260
- const colorMultiply = [color.r, color.g, color.b, color.a];
1261
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1262
- additiveColor.b * 255, additiveColor.a * 255];
1263
- for (let i = 0; i < data.length; ++i)
1264
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1265
- }
1266
- else
1267
- {
1268
- // RGB only, faster alpha left intact for the caller
1269
- for (let i = 0; i < data.length; i+=4)
1270
- {
1271
- data[i ] *= color.r;
1272
- data[i+1] *= color.g;
1273
- data[i+2] *= color.b;
1274
- }
1275
- }
1276
- workReadContext.putImageData(imageData, 0, 0);
1277
- return workReadCanvas;
1278
- }
1279
-
1280
- /** Helper function to draw an image with color and additive color applied
1281
- * This is slower then normal drawImage when color is applied
1282
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1283
- * @param {HTMLImageElement|OffscreenCanvas} image
1284
- * @param {number} sx
1285
- * @param {number} sy
1286
- * @param {number} sWidth
1287
- * @param {number} sHeight
1288
- * @param {number} dx
1289
- * @param {number} dy
1290
- * @param {number} dWidth
1291
- * @param {number} dHeight
1292
- * @param {Color} color
1293
- * @param {Color} [additiveColor]
1294
- * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1295
- * @memberof Draw */
1296
- function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1297
- {
1298
- const sx2 = bleed;
1299
- const sy2 = bleed;
1300
- sWidth = max(1,sWidth|0);
1301
- sHeight = max(1,sHeight|0);
1302
- const sWidth2 = sWidth - 2*bleed;
1303
- const sHeight2 = sHeight - 2*bleed;
1304
- if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
1305
- {
1306
- // white texture with no additive alpha, no need to tint
1307
- context.globalAlpha = color.a;
1308
- context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1309
- context.globalAlpha = 1;
1310
- }
1311
- else
1312
- {
1313
- // copy to offscreen canvas
1314
- workReadCanvas.width = sWidth;
1315
- workReadCanvas.height = sHeight;
1316
- workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
1317
-
1318
- // tint image using offscreen work context
1319
- const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
1320
- const data = imageData.data;
1321
- if (additiveColor && !isBlack(additiveColor))
1322
- {
1323
- // slower path with additive color
1324
- const colorMultiply = [color.r, color.g, color.b, color.a];
1325
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255, additiveColor.b * 255, additiveColor.a * 255];
1326
- for (let i = 0; i < data.length; ++i)
1327
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1328
- workReadContext.putImageData(imageData, 0, 0);
1329
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1330
- }
1331
- else
1332
- {
1333
- // faster path with no additive color
1334
- for (let i = 0; i < data.length; i+=4)
1335
- {
1336
- data[i ] *= color.r;
1337
- data[i+1] *= color.g;
1338
- data[i+2] *= color.b;
1339
- }
1340
- workReadContext.putImageData(imageData, 0, 0);
1341
- context.globalAlpha = color.a;
1342
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1343
- context.globalAlpha = 1;
1344
- }
1345
- }
1346
- }
1347
-
1348
-
1349
- /** Returns true if fullscreen mode is active
1350
- * @return {boolean}
1351
- * @memberof Draw */
1352
- function isFullscreen() { return !!document.fullscreenElement; }
1353
-
1354
- /** Toggle fullscreen mode
1355
- * @memberof Draw */
1356
- function toggleFullscreen()
1357
- {
1358
- const rootElement = mainCanvas.parentElement;
1359
- if (isFullscreen())
1360
- {
1361
- if (document.exitFullscreen)
1362
- document.exitFullscreen();
1363
- }
1364
- else if (rootElement.requestFullscreen)
1365
- rootElement.requestFullscreen();
1366
- }
1367
-
1368
- /** Set the cursor style
1369
- * @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc)
1370
- * @memberof Draw */
1371
- function setCursor(cursorStyle = 'auto')
1372
- {
1373
- const rootElement = mainCanvas.parentElement;
1374
- rootElement.style.cursor = cursorStyle;
1375
- }
1376
-
1377
- ///////////////////////////////////////////////////////////////////////////////
1378
-
1379
- /** Engine font image, 8x8 font provided by the engine
1380
- * @type {ImageFont}
1381
- * @memberof Draw */
1382
- let engineImageFont;
1383
-
1384
- /**
1385
- * Image Font Object - Draw text by using tiles in an image
1386
- * - 96 characters (from space to tilde) are stored in an image
1387
- * - A 8x8 default engine font is supplied for general use
1388
- * - This system is WebGL enabled for fast text rendering
1389
- * - Fonts can also be colored and scaled along each axis
1390
- *
1391
- * @memberof Draw
1392
- * @example
1393
- * // use built in font
1394
- * const font = engineImageFont;
1395
- *
1396
- * // draw text
1397
- * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1398
- */
1399
- class ImageFont
1400
- {
1401
- /** Create an image font
1402
- * @param {TileInfo} tileInfo - Tile info of first character in font
1403
- */
1404
- constructor(tileInfo)
1405
- {
1406
- ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1407
-
1408
- /** @property {TileInfo} - Tile info for the font */
1409
- this.tileInfo = tileInfo.frame(0);
1410
- }
1411
-
1412
- /** Draw text in world space using the image font
1413
- * @param {string|number} text
1414
- * @param {Vector2} pos
1415
- * @param {Vector2|number} [size]
1416
- * @param {boolean} [center=true]
1417
- * @param {Color} [color=WHITE]
1418
- * @param {boolean} [useWebGL=glEnable]
1419
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1420
- */
1421
- drawText(text, pos, size=1, center, color, useWebGL, context)
1422
- {
1423
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1424
-
1425
- if (typeof size === 'number')
1426
- {
1427
- // if size is a number, make it a vector
1428
- ASSERT(size > 0);
1429
- size *= cameraScale;
1430
- size = new Vector2(size, size);
1431
- }
1432
- else
1433
- size = size.scale(cameraScale);
1434
- this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1435
- }
1436
-
1437
- /** Draw text in screen space using the image font
1438
- * @param {string|number} text
1439
- * @param {Vector2} pos
1440
- * @param {Vector2|number} size
1441
- * @param {boolean} [center]
1442
- * @param {Color} [color=WHITE]
1443
- * @param {boolean} [useWebGL=glEnable]
1444
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1445
- */
1446
- drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
1447
- {
1448
- ASSERT(isStringLike(text), 'text must be a string');
1449
- ASSERT(isVector2(pos), 'pos must be a vec2');
1450
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1451
- ASSERT(isColor(color), 'color must be a color');
1452
-
1453
- // if size is a number, make it a vector
1454
- size = typeof size === 'number' ? new Vector2(size, size) : size;
1455
-
1456
- // precache objects for drawing
1457
- const drawPos = new Vector2;
1458
- const tileInfo = this.tileInfo;
1459
- const padding = tileInfo.padding;
1460
- const sizePaddedX = tileInfo.size.x + padding*2;
1461
- const sizePaddedY = tileInfo.size.y + padding*2;
1462
- const cols = tileInfo.textureInfo.size.x / sizePaddedX |0;
1463
-
1464
- // draw each line of text
1465
- (text+'').split('\n').forEach((line, j)=>
1466
- {
1467
- const centerOffset = center ? (line.length-1) * size.x / 2 : 0;
1468
- for (let i=line.length; i--;)
1469
- {
1470
- // get the character index
1471
- const charCode = line.charCodeAt(i);
1472
- const index = charCode < 32 || charCode > 127 ?
1473
- 95 : charCode - 32; // handle out of range characters
1474
-
1475
- // get the position of the tile
1476
- const x = index % cols;
1477
- const y = index / cols |0;
1478
- tileInfo.pos.x = x*sizePaddedX + padding;
1479
- tileInfo.pos.y = y*sizePaddedY + padding;
1480
-
1481
- // snap the glyph edges to whole pixels
1482
- // tiles are drawn from their center, so snapping the center
1483
- // to a whole pixel puts the edges on half pixels when the
1484
- // size is even, and a row or column of the glyph then has
1485
- // no pixel center inside it and is not rasterized at all
1486
- // ceil picks the nearest aligned position, breaking ties
1487
- // downward to match how this used to truncate
1488
- drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
1489
- drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
1490
- drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1491
- }
1492
- });
1493
- }
1494
- }
1495
-
1496
- // load engine font, called automatically on startup
1497
- async function imageFontInit()
1498
- {
1499
- const image = new Image;
1500
- await new Promise(resolve =>
1501
- {
1502
- image.onerror = image.onload = resolve;
1503
- image.crossOrigin = 'anonymous';
1504
- image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg==';
1505
- });
1506
-
1507
- const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
1508
- const textureInfo = new TextureInfo(image);
1509
- const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
1510
- engineImageFont = new ImageFont(tileInfo);
1
+ /**
2
+ * LittleJS Drawing System
3
+ * - Hybrid rendering with both Canvas2D and WebGL support
4
+ * - Optimized tile sheet sprite rendering using WebGL batching
5
+ * - Primitive drawing for polygons, ellipses, and lines
6
+ * - Tile-based rendering with TileInfo and TextureInfo classes
7
+ * - Text rendering with custom fonts and ImageFont support
8
+ * - Color and additive color blending for effects
9
+ * - Rotation, mirroring, and scaling transformations
10
+ * - Camera system with position, scale, and rotation
11
+ * - Multiple canvas support (main, WebGL, work canvases)
12
+ * - Gradient fills and outlined shapes
13
+ * - Image manipulation and color tinting
14
+ *
15
+ * Rendering Architecture:
16
+ * - glCanvas: WebGL canvas for accelerated sprite batch rendering
17
+ * - mainCanvas: Canvas2D overlay for text, UI, and custom drawing
18
+ * - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter
19
+ *
20
+ * @namespace Draw
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ /** The primary 2D canvas visible to the user
26
+ * @type {HTMLCanvasElement}
27
+ * @memberof Draw */
28
+ let mainCanvas;
29
+
30
+ /** 2d context for mainCanvas
31
+ * - Scaled by canvasPixelRatio, so drawing to it is in css pixels
32
+ * - getImageData and putImageData ignore that scale and work in backing store
33
+ * pixels, so use workReadCanvas to read pixels back instead of this
34
+ * @type {CanvasRenderingContext2D}
35
+ * @memberof Draw */
36
+ let mainContext;
37
+
38
+ /** The default 2d context to use for drawing, usually mainContext
39
+ * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
40
+ * @memberof Draw */
41
+ let drawContext;
42
+
43
+ /** Offscreen canvas that can be used for image processing
44
+ * @type {OffscreenCanvas}
45
+ * @memberof Draw */
46
+ let workCanvas;
47
+
48
+ /** Offscreen canvas that can be used for image processing
49
+ * @type {OffscreenCanvasRenderingContext2D}
50
+ * @memberof Draw */
51
+ let workContext;
52
+
53
+ /** Offscreen canvas with willReadFrequently that can be used for image processing
54
+ * @type {OffscreenCanvas}
55
+ * @memberof Draw */
56
+ let workReadCanvas;
57
+
58
+ /** Offscreen canvas with willReadFrequently that can be used for image processing
59
+ * @type {OffscreenCanvasRenderingContext2D}
60
+ * @memberof Draw */
61
+ let workReadContext;
62
+
63
+ /** Extra canvas to composite behind the engine canvases when combining canvases
64
+ * Set by plugins that render to their own canvas below the LittleJS canvases
65
+ * @type {HTMLCanvasElement}
66
+ * @memberof Draw */
67
+ let backgroundCanvas;
68
+
69
+ /** The size of the main canvas (and other secondary canvases) in css pixels
70
+ * - This is the screen space coordinate system, matching mousePos
71
+ * - With canvasPixelRatio set the backing store is larger than this
72
+ * @type {Vector2}
73
+ * @memberof Draw */
74
+ let mainCanvasSize = vec2();
75
+
76
+ /** Array containing texture info for batch rendering system
77
+ * @type {Array<TextureInfo>}
78
+ * @memberof Draw */
79
+ let textureInfos = [];
80
+
81
+ /** Keeps track of how many draw calls there were each frame for debugging
82
+ * @type {number}
83
+ * @memberof Draw */
84
+ let drawCount;
85
+
86
+ /** Keeps track of how many primitives were drawn each frame for debugging
87
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
88
+ * @type {number}
89
+ * @memberof Draw */
90
+ let primitiveCount;
91
+
92
+ // internal predicates for tint short-circuiting in canvas2D draw paths
93
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
94
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
95
+ /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
96
+ /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
97
+
98
+ ///////////////////////////////////////////////////////////////////////////////
99
+
100
+ /**
101
+ * Create a tile info object using a grid based system
102
+ * - This can take vecs or floats for easier use and conversion
103
+ * - If an index is passed in, the tile size and index will determine the position
104
+ * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
105
+ * @param {Vector2|number} [size] - Size of tile in pixels
106
+ * @param {TextureInfo|number} [texture] - Texture index or info to use
107
+ * @param {number} [padding] - How many pixels padding around tiles
108
+ * @param {number} [bleed] - How many pixels smaller to draw tiles
109
+ * @return {TileInfo}
110
+ * @example
111
+ * tile(2) // a tile at index 2 using the default tile size of 16
112
+ * tile(5, 8) // a tile at index 5 using a tile size of 8
113
+ * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
114
+ * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
115
+ * @memberof Draw */
116
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
117
+ {
118
+ ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
119
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
120
+ ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
121
+ ASSERT(isNumber(padding), 'padding must be a number');
122
+
123
+ if (headlessMode) return new TileInfo;
124
+
125
+ if (typeof size === 'number')
126
+ {
127
+ // if size is a number, make it a vector
128
+ ASSERT(size > 0);
129
+ size = new Vector2(size, size);
130
+ }
131
+
132
+ // create tile info object
133
+ const textureInfo = typeof texture === 'number' ?
134
+ textureInfos[texture] : texture;
135
+ ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
136
+ ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
137
+
138
+ // get the position of the tile
139
+ const sizePaddedX = size.x + padding*2;
140
+ const sizePaddedY = size.y + padding*2;
141
+ let x, y;
142
+ if (typeof index === 'number')
143
+ {
144
+ const cols = textureInfo.size.x / sizePaddedX |0;
145
+ x = index % cols;
146
+ y = index / cols |0;
147
+ }
148
+ else
149
+ {
150
+ x = index.x;
151
+ y = index.y;
152
+ }
153
+ const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding);
154
+ return new TileInfo(pos, size, textureInfo, padding, bleed);
155
+ }
156
+
157
+ /**
158
+ * Tile Info - Stores info about how to draw a tile
159
+ * @memberof Draw
160
+ */
161
+ class TileInfo
162
+ {
163
+ /** Create a tile info object
164
+ * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
165
+ * @param {Vector2} [size] - Size of tile in pixels
166
+ * @param {TextureInfo} [textureInfo] - Texture info to use
167
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
168
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
169
+ * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
170
+ */
171
+ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
172
+ {
173
+ /** @property {Vector2} - Top left corner of tile in pixels */
174
+ this.pos = pos.copy();
175
+ /** @property {Vector2} - Size of tile in pixels */
176
+ this.size = size.copy();
177
+ /** @property {number} - How many pixels padding around tiles */
178
+ this.padding = padding;
179
+ /** @property {TextureInfo} - The texture info for this tile */
180
+ this.textureInfo = textureInfo;
181
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
182
+ this.bleed = bleed;
183
+ /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
184
+ this.columns = columns;
185
+ }
186
+
187
+ /** Returns a copy of this tile offset by a vector
188
+ * @param {Vector2} offset - Offset to apply in pixels
189
+ * @return {TileInfo}
190
+ */
191
+ offset(offset)
192
+ { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
193
+
194
+ /** Returns a copy of this tile offset by a number of animation frames
195
+ * Frames wrap down to the next row if columns is set
196
+ * @param {number} frame - Offset to apply in animation frames
197
+ * @return {TileInfo}
198
+ */
199
+ frame(frame)
200
+ {
201
+ ASSERT(typeof frame === 'number');
202
+ const w = this.size.x + this.padding*2;
203
+ const h = this.size.y + this.padding*2;
204
+ const x = (this.columns ? frame % this.columns : frame) * w;
205
+ const y = (this.columns ? frame / this.columns | 0 : 0) * h;
206
+ ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
207
+ ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
208
+ return this.offset(new Vector2(x, y));
209
+ }
210
+
211
+ /** Set how many frames per row this tile uses, so frame() can wrap
212
+ * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
213
+ * @return {TileInfo}
214
+ */
215
+ setColumns(columns=0)
216
+ {
217
+ ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
218
+ this.columns = columns;
219
+ return this;
220
+ }
221
+
222
+ /**
223
+ * Returns a tile info for an index using this tile as reference
224
+ * @param {Vector2|number} [index=0]
225
+ * @return {TileInfo}
226
+ */
227
+ index(index)
228
+ { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
229
+
230
+ /**
231
+ * Set this tile to use a full image in a texture info
232
+ * @param {TextureInfo} [textureInfo]
233
+ * @return {TileInfo}
234
+ */
235
+ setFullImage(textureInfo=this.textureInfo)
236
+ {
237
+ this.textureInfo = textureInfo;
238
+ this.pos = new Vector2;
239
+ this.size = textureInfo.size.copy();
240
+ this.bleed = this.padding = this.columns = 0;
241
+ return this;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Tile Info - Stores info about each texture
247
+ * @memberof Draw
248
+ */
249
+ class TextureInfo
250
+ {
251
+ /**
252
+ * Create a TextureInfo, called automatically by the engine
253
+ * @param {HTMLImageElement|OffscreenCanvas} image
254
+ * @param {boolean} [useWebGL] - Should use WebGL if available?
255
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
256
+ */
257
+ constructor(image, useWebGL=true, wrap=false)
258
+ {
259
+ /** @property {HTMLImageElement|OffscreenCanvas} - image source */
260
+ this.image = image;
261
+ /** @property {Vector2} - size of the image */
262
+ this.size = image ? vec2(image.width, image.height) : vec2();
263
+ /** @property {Vector2} - inverse of the size, cached for rendering */
264
+ this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
265
+ /** @property {WebGLTexture|undefined} - WebGL texture
266
+ * @type {WebGLTexture|undefined} */
267
+ this.glTexture = undefined;
268
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
269
+ this.wrap = wrap;
270
+ useWebGL && this.createWebGLTexture();
271
+ }
272
+
273
+ /** Creates the WebGL texture, updates if already created */
274
+ createWebGLTexture() { glRegisterTextureInfo(this); }
275
+
276
+ /** Destroys the WebGL texture */
277
+ destroyWebGLTexture() { glUnregisterTextureInfo(this); }
278
+
279
+ /** Check if the texture is webgl enabled
280
+ * @return {boolean} */
281
+ hasWebGL() { return !!this.glTexture; }
282
+
283
+ /** Set the wrap mode for this texture
284
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
285
+ setWrap(wrap=true)
286
+ {
287
+ this.wrap = wrap;
288
+ glSetTextureWrap(this.glTexture, wrap);
289
+ }
290
+ }
291
+
292
+ ///////////////////////////////////////////////////////////////////////////////
293
+ /**
294
+ * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
295
+ * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
296
+ * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
297
+ * - loop, play and pingPong each start over from the first frame; stop holds the current one
298
+ * - Frames follow each other along the row, as tileInfo.frame counts them
299
+ * @example
300
+ * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
301
+ * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
302
+ * // in update: this.tileInfo = (attack.isDone ? walk : attack).tileInfo;
303
+ * @memberof Draw
304
+ */
305
+ class SpriteAnimation
306
+ {
307
+ /** Create an animation over a run of frames, looping from the start
308
+ * @param {TileInfo} tileInfo - The first frame
309
+ * @param {number} frameCount - How many frames, one or more
310
+ * @param {number} [frameTime] - Seconds each frame shows for */
311
+ constructor(tileInfo, frameCount, frameTime=.1)
312
+ {
313
+ ASSERT(tileInfo instanceof TileInfo, 'the first frame must be a TileInfo');
314
+ ASSERT(frameCount >= 1 && frameTime > 0, 'an animation needs at least one frame and a positive frame time');
315
+ /** @property {TileInfo} - The first frame, the others follow it along the row */
316
+ this.firstTile = tileInfo;
317
+ /** @property {number} - How many frames */
318
+ this.frameCount = frameCount;
319
+ /** @property {number} - Seconds each frame shows for */
320
+ this.frameTime = frameTime;
321
+ /** @property {number} - Rate multiplier, 2 plays twice as fast; set it before starting */
322
+ this.speed = 1;
323
+ /** @property {string} - How it runs: 'loop', 'once' or 'pingPong', set by loop, play and pingPong */
324
+ this.mode = 'loop';
325
+ /** @property {number} - Engine time it started at */
326
+ this.startTime = time;
327
+ /** @property {number|undefined} - The frame held by stop, undefined while running
328
+ * @type {number|undefined} */
329
+ this.heldFrame = undefined;
330
+ }
331
+
332
+ /** Start over from the first frame and repeat forever
333
+ * @return {SpriteAnimation} */
334
+ loop() { return this.restart('loop'); }
335
+
336
+ /** Start over from the first frame, run through once and hold the last frame
337
+ * @return {SpriteAnimation} */
338
+ play() { return this.restart('once'); }
339
+
340
+ /** Start over from the first frame and run there and back forever
341
+ * @return {SpriteAnimation} */
342
+ pingPong() { return this.restart('pingPong'); }
343
+
344
+ /** Hold the current frame
345
+ * @return {SpriteAnimation} */
346
+ stop() { this.heldFrame = this.frame; return this; }
347
+
348
+ /** Start over from the first frame in a mode
349
+ * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
350
+ * @return {SpriteAnimation} */
351
+ restart(mode=this.mode)
352
+ {
353
+ this.mode = mode;
354
+ this.startTime = time;
355
+ this.heldFrame = undefined;
356
+ return this;
357
+ }
358
+
359
+ /** How many frames have gone by since the start, fractional
360
+ * @return {number} */
361
+ get elapsedFrames() { return (time - this.startTime) * this.speed / this.frameTime; }
362
+
363
+ /** The frame showing now, 0 to frameCount-1
364
+ * @return {number} */
365
+ get frame()
366
+ {
367
+ if (this.heldFrame !== undefined)
368
+ return this.heldFrame;
369
+ const n = this.frameCount, f = floor(this.elapsedFrames);
370
+ if (this.mode == 'once')
371
+ return min(f, n - 1);
372
+ if (this.mode == 'loop')
373
+ return f % n;
374
+ const period = max(2 * n - 2, 1), k = f % period; // there and back, the ends once each
375
+ return k < n ? k : period - k;
376
+ }
377
+
378
+ /** The tile of the frame showing now
379
+ * @return {TileInfo} */
380
+ get tileInfo() { return this.firstTile.frame(this.frame); }
381
+
382
+ /** True once a play has shown its last frame for its time
383
+ * @return {boolean} */
384
+ get isDone() { return this.mode == 'once' && this.heldFrame === undefined && this.elapsedFrames >= this.frameCount; }
385
+ }
386
+
387
+ ///////////////////////////////////////////////////////////////////////////////
388
+ /**
389
+ * Shader - A custom fragment shader for objects and draws, 2D or 3D
390
+ * - Write a mainImage function in the post processing style, the renderer wraps it with its own program
391
+ * - It gives the surface color, then the object's color and additive color apply in 2D, and the lighting,
392
+ * shadows and fog in 3D; set emissive to 1 on a 3D object for the snippet's color to be final
393
+ * - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws
394
+ * - Draws that share a Shader share a batch; with no Shader set nothing changes
395
+ * - In 2D it shades textured draws, untextured ones like drawRect draw as they are
396
+ * - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug
397
+ * - Make each Shader once, at init, and share it; every one made lives for the session with its programs
398
+ * - Names in both renderers: iChannel0 the texture, iTime, iResolution, and localUV, 0 to 1 across the sprite
399
+ * or the mesh's own uv
400
+ * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, lightCount,
401
+ * lights[i], lightColors[i] and shadow()
402
+ * @example
403
+ * const fade = new Shader(`
404
+ * void mainImage(out vec4 c, vec2 uv)
405
+ * {
406
+ * c = texture(iChannel0, uv);
407
+ * c.a *= .5 + .5*sin(iTime);
408
+ * }`);
409
+ * obj.shader = fade;
410
+ * @memberof Draw
411
+ */
412
+ class Shader
413
+ {
414
+ /** Create a shader from a fragment snippet that defines void mainImage(out vec4 c, vec2 uv)
415
+ * @param {string} fragmentCode */
416
+ constructor(fragmentCode)
417
+ {
418
+ ASSERT(isStringLike(fragmentCode) && String(fragmentCode).includes('mainImage'), 'a Shader needs fragment code that defines mainImage');
419
+ /** @property {string} - The mainImage snippet */
420
+ this.fragmentCode = String(fragmentCode);
421
+ /** @property {WebGLProgram|undefined} - The 2D program, compiled by the first draw that needs it, read only
422
+ * @type {WebGLProgram|undefined} */
423
+ this.program = undefined;
424
+ /** @property {WebGLProgram|undefined} - The 3D program, compiled by the 3D plugin the same way, read only
425
+ * @type {WebGLProgram|undefined} */
426
+ this.program3D = undefined;
427
+ glShaderObjects.push(this); // a lost context drops the programs of every one
428
+ }
429
+ }
430
+
431
+ ///////////////////////////////////////////////////////////////////////////////
432
+ // Drawing functions
433
+
434
+ /** Draw textured tile centered in world space
435
+ * @param {Vector2} pos - Center of the tile in world space
436
+ * @param {Vector2} [size=vec2(1)] - Size of the tile in world space
437
+ * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
438
+ * @param {Color} [color=WHITE] - Color to modulate with
439
+ * @param {number} [angle] - Angle to rotate by
440
+ * @param {boolean} [mirror] - Is image flipped along the Y axis?
441
+ * @param {Color} [additiveColor] - Additive color to be applied if any
442
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
443
+ * @param {boolean} [screenSpace=false] - Are the pos and size are in screen space?
444
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
445
+ * @memberof Draw */
446
+ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
447
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
448
+ {
449
+ ASSERT(isVector2(pos), 'pos must be a vec2');
450
+ ASSERT(isVector2(size), 'size must be a vec2');
451
+ ASSERT(isColor(color), 'color is invalid');
452
+ ASSERT(isNumber(angle), 'angle must be a number');
453
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
454
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
455
+
456
+ const textureInfo = tileInfo?.textureInfo;
457
+ const bleed = tileInfo?.bleed ?? 0;
458
+ if (useWebGL && glEnable)
459
+ {
460
+ ASSERT(!!glContext, 'WebGL is not enabled!');
461
+ if (screenSpace)
462
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
463
+ if (textureInfo)
464
+ {
465
+ // calculate uvs and render
466
+ const sizeInverse = textureInfo.sizeInverse;
467
+ const x = tileInfo.pos.x * sizeInverse.x;
468
+ const y = tileInfo.pos.y * sizeInverse.y;
469
+ const w = tileInfo.size.x * sizeInverse.x;
470
+ const h = tileInfo.size.y * sizeInverse.y;
471
+ glSetTexture(textureInfo.glTexture);
472
+ if (bleed)
473
+ {
474
+ const bleedX = sizeInverse.x*bleed;
475
+ const bleedY = sizeInverse.y*bleed;
476
+ glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
477
+ x + bleedX, y + bleedY,
478
+ x - bleedX + w, y - bleedY + h,
479
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
480
+ }
481
+ else
482
+ {
483
+ glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
484
+ x, y, x + w, y + h,
485
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
486
+ }
487
+ }
488
+ else
489
+ {
490
+ // untextured: fold color+additive to match the Canvas2D path's
491
+ // color.add(additiveColor) on line ~337.
492
+ const combined = additiveColor ? color.add(additiveColor) : color;
493
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
494
+ }
495
+ }
496
+ else
497
+ {
498
+ // normal canvas 2D rendering method (slower)
499
+ ++drawCount;
500
+ ++primitiveCount;
501
+ drawCanvas2D(pos, size, angle, mirror, (context)=>
502
+ {
503
+ if (textureInfo)
504
+ {
505
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
506
+ context.scale(1, -1);
507
+ // calculate uvs and render
508
+ const x = tileInfo.pos.x, y = tileInfo.pos.y;
509
+ const w = tileInfo.size.x, h = tileInfo.size.y;
510
+ drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed);
511
+ }
512
+ else
513
+ {
514
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
515
+ const c = additiveColor ? color.add(additiveColor) : color;
516
+ context.fillStyle = c.toString();
517
+ context.fillRect(-.5, -.5, 1, 1);
518
+ }
519
+ }, screenSpace, context);
520
+ }
521
+ }
522
+
523
+ /** Draw colored rect centered on pos
524
+ * @param {Vector2} pos
525
+ * @param {Vector2} [size=vec2(1)]
526
+ * @param {Color} [color=WHITE]
527
+ * @param {number} [angle]
528
+ * @param {boolean} [useWebGL=glEnable]
529
+ * @param {boolean} [screenSpace]
530
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
531
+ * @memberof Draw */
532
+ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
533
+ {
534
+ drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context);
535
+ }
536
+
537
+ /** Draw a rect centered on pos with a gradient from top to bottom
538
+ * @param {Vector2} pos
539
+ * @param {Vector2} [size=vec2(1)]
540
+ * @param {Color} [colorTop=WHITE]
541
+ * @param {Color} [colorBottom=CLEAR_WHITE]
542
+ * @param {number} [angle]
543
+ * @param {boolean} [useWebGL=glEnable]
544
+ * @param {boolean} [screenSpace]
545
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
546
+ * @memberof Draw */
547
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
548
+ {
549
+ ASSERT(isVector2(pos), 'pos must be a vec2');
550
+ ASSERT(isVector2(size), 'size must be a vec2');
551
+ ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
552
+ ASSERT(isNumber(angle), 'angle must be a number');
553
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
554
+
555
+ if (useWebGL && glEnable)
556
+ {
557
+ ASSERT(!!glContext, 'WebGL is not enabled!');
558
+ if (screenSpace)
559
+ {
560
+ // convert to world space
561
+ pos = screenToWorld(pos);
562
+ size = size.scale(1/cameraScale);
563
+ angle += cameraAngle;
564
+ }
565
+ // build 4 corner points for the rectangle
566
+ const points = [], colors = [];
567
+ const halfSizeX = size.x/2, halfSizeY = size.y/2;
568
+ const colorTopInt = colorTop.rgbaInt();
569
+ const colorBottomInt = colorBottom.rgbaInt();
570
+ const c = cos(-angle), s = sin(-angle);
571
+ for (let i=4; i--;)
572
+ {
573
+ const x = i & 1 ? halfSizeX : -halfSizeX;
574
+ const y = i & 2 ? halfSizeY : -halfSizeY;
575
+ const rx = x * c - y * s;
576
+ const ry = x * s + y * c;
577
+ const color = i & 2 ? colorTopInt : colorBottomInt;
578
+ points.push(vec2(pos.x + rx, pos.y + ry));
579
+ colors.push(color);
580
+ }
581
+ glDrawColoredPoints(points, colors);
582
+ }
583
+ else
584
+ {
585
+ // normal canvas 2D rendering method (slower)
586
+ ++drawCount;
587
+ ++primitiveCount;
588
+ drawCanvas2D(pos, size, angle, false, (context)=>
589
+ {
590
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
591
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
592
+ gradient.addColorStop(0, colorTop.toString());
593
+ gradient.addColorStop(1, colorBottom.toString());
594
+ context.fillStyle = gradient;
595
+ context.fillRect(-.5, -.5, 1, 1);
596
+ }, screenSpace, context);
597
+ }
598
+ }
599
+
600
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
601
+ * Useful for backgrounds, repeating patterns, and seamless fills.
602
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
603
+ * @param {Vector2} pos - Center of the rect in world space
604
+ * @param {Vector2} size - Size of the rect in world space
605
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
606
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
607
+ * @param {Color} [color=WHITE] - Color to modulate with
608
+ * @param {number} [angle=0] - Angle to rotate by
609
+ * @param {Color} [additiveColor] - Additive color to be applied if any
610
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
611
+ * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
612
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
613
+ * @memberof Draw */
614
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
615
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
616
+ {
617
+ ASSERT(isVector2(pos), 'pos must be a vec2');
618
+ ASSERT(isVector2(size), 'size must be a vec2');
619
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
620
+ ASSERT(isColor(color), 'color is invalid');
621
+ ASSERT(isNumber(angle), 'angle must be a number');
622
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
623
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
624
+ ASSERT(!(texture instanceof TileInfo),
625
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
626
+
627
+ // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
628
+ if (headlessMode) return;
629
+
630
+ // resolve texture argument: TextureInfo or index
631
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
632
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
633
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
634
+ ASSERT(textureInfo.wrap,
635
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
636
+
637
+ if (useWebGL && glEnable)
638
+ {
639
+ ASSERT(!!glContext, 'WebGL is not enabled!');
640
+ if (screenSpace)
641
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
642
+ glSetTexture(textureInfo.glTexture);
643
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
644
+ 0, 0, wrapCount.x, wrapCount.y,
645
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
646
+ return;
647
+ }
648
+
649
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
650
+ ++drawCount;
651
+ ++primitiveCount;
652
+
653
+ if (!screenSpace)
654
+ {
655
+ pos = worldToScreen(pos);
656
+ size = size.scale(cameraScale);
657
+ angle -= cameraAngle;
658
+ }
659
+
660
+ // pick image source: raw, or tinted bake. Match drawImageColor's
661
+ // "no tint needed" predicate so behavior stays consistent.
662
+ const noTint = !canvasColorTiles ||
663
+ (additiveColor
664
+ ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
665
+ : isWhite(color));
666
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
667
+ // in that case globalAlpha must NOT also apply color.a
668
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
669
+ const source = noTint
670
+ ? textureInfo.image
671
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
672
+
673
+ context = context || drawContext;
674
+ context.save();
675
+ context.translate(pos.x + .5, pos.y + .5);
676
+ context.rotate(angle);
677
+ context.globalAlpha = alphaBaked ? 1 : color.a;
678
+
679
+ const pattern = context.createPattern(source, 'repeat');
680
+ // map pattern-source pixels into user space so the rect contains
681
+ // wrapCount.x × wrapCount.y repeats
682
+ const m = new DOMMatrix()
683
+ .translate(-size.x/2, -size.y/2)
684
+ .scale(size.x / (wrapCount.x * source.width),
685
+ size.y / (wrapCount.y * source.height));
686
+ pattern.setTransform(m);
687
+ context.fillStyle = pattern;
688
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
689
+ context.globalAlpha = 1;
690
+ context.restore();
691
+ }
692
+
693
+ /** Draw connected lines between a series of points
694
+ * @param {Array<Vector2>} points
695
+ * @param {number} [width]
696
+ * @param {Color} [color=WHITE]
697
+ * @param {boolean} [wrap] - Should the last point connect to the first?
698
+ * @param {Vector2} [pos=vec2()] - Offset to apply
699
+ * @param {number} [angle] - Angle to rotate by
700
+ * @param {boolean} [useWebGL=glEnable]
701
+ * @param {boolean} [screenSpace]
702
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
703
+ * @memberof Draw */
704
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
705
+ {
706
+ ASSERT(isArray(points), 'points must be an array');
707
+ ASSERT(isNumber(width), 'width must be a number');
708
+ ASSERT(isColor(color), 'color is invalid');
709
+ ASSERT(isVector2(pos), 'pos must be a vec2');
710
+ ASSERT(isNumber(angle), 'angle must be a number');
711
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
712
+
713
+ if (useWebGL && glEnable)
714
+ {
715
+ ASSERT(!!glContext, 'WebGL is not enabled!');
716
+ let size = vec2(1);
717
+ if (screenSpace)
718
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
719
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
720
+ }
721
+ else
722
+ {
723
+ // normal canvas 2D rendering method (slower)
724
+ ++drawCount;
725
+ ++primitiveCount;
726
+ drawCanvas2D(pos, vec2(1), angle, false, (context)=>
727
+ {
728
+ context.strokeStyle = color.toString();
729
+ context.lineWidth = width;
730
+ context.beginPath();
731
+ for (let i=0; i<points.length; ++i)
732
+ {
733
+ const point = points[i];
734
+ context.lineTo(point.x, point.y);
735
+ }
736
+ wrap && context.closePath();
737
+ context.stroke();
738
+ }, screenSpace, context);
739
+ }
740
+ }
741
+
742
+ /** Draw colored line between two points
743
+ * @param {Vector2} posA
744
+ * @param {Vector2} posB
745
+ * @param {number} [width]
746
+ * @param {Color} [color=WHITE]
747
+ * @param {Vector2} [pos=vec2()] - Offset to apply
748
+ * @param {number} [angle] - Angle to rotate by
749
+ * @param {boolean} [useWebGL=glEnable]
750
+ * @param {boolean} [screenSpace]
751
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
752
+ * @memberof Draw */
753
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
754
+ {
755
+ const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
756
+ const size = vec2(width, halfDelta.length()*2);
757
+ pos = pos.add(posA.add(halfDelta));
758
+ if (screenSpace)
759
+ halfDelta.y *= -1; // flip angle Y if screen space
760
+ angle += halfDelta.angle();
761
+ drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
762
+ }
763
+
764
+ /** Draw colored regular polygon using passed in number of sides
765
+ * @param {Vector2} pos
766
+ * @param {Vector2} [size=vec2(1)]
767
+ * @param {number} [sides]
768
+ * @param {Color} [color=WHITE]
769
+ * @param {number} [lineWidth]
770
+ * @param {Color} [lineColor=BLACK]
771
+ * @param {number} [angle]
772
+ * @param {boolean} [useWebGL=glEnable]
773
+ * @param {boolean} [screenSpace]
774
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
775
+ * @memberof Draw */
776
+ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
777
+ {
778
+ ASSERT(isVector2(size), 'size must be a vec2');
779
+ ASSERT(isNumber(sides), 'sides must be a number');
780
+
781
+ // build regular polygon points
782
+ const points = [];
783
+ const sizeX = size.x/2, sizeY = size.y/2;
784
+ for (let i=sides; i--;)
785
+ {
786
+ const a = (i/sides)*PI*2;
787
+ points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
788
+ }
789
+ drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
790
+ }
791
+
792
+ /** Draw colored polygon using passed in points
793
+ * @param {Array<Vector2>} points - Array of Vector2 points
794
+ * @param {Color} [color=WHITE]
795
+ * @param {number} [lineWidth]
796
+ * @param {Color} [lineColor=BLACK]
797
+ * @param {Vector2} [pos=vec2()] - Offset to apply
798
+ * @param {number} [angle] - Angle to rotate by
799
+ * @param {boolean} [useWebGL=glEnable]
800
+ * @param {boolean} [screenSpace]
801
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
802
+ * @memberof Draw */
803
+ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
804
+ {
805
+ ASSERT(isVector2(pos), 'pos must be a vec2');
806
+ ASSERT(isArray(points), 'points must be an array');
807
+ ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
808
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
809
+ ASSERT(isNumber(angle), 'angle must be a number');
810
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
811
+
812
+ if (useWebGL && glEnable)
813
+ {
814
+ ASSERT(!!glContext, 'WebGL is not enabled!');
815
+ let size = vec2(1);
816
+ if (screenSpace)
817
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
818
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
819
+ if (lineWidth > 0)
820
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
821
+ }
822
+ else
823
+ {
824
+ drawCanvas2D(pos, vec2(1), angle, false, context=>
825
+ {
826
+ context.fillStyle = color.toString();
827
+ context.beginPath();
828
+ for (const point of points)
829
+ context.lineTo(point.x, point.y);
830
+ context.closePath();
831
+ context.fill();
832
+ if (lineWidth)
833
+ {
834
+ context.strokeStyle = lineColor.toString();
835
+ context.lineWidth = lineWidth;
836
+ context.stroke();
837
+ }
838
+ }, screenSpace, context);
839
+ }
840
+ }
841
+
842
+ /** Draw colored ellipse using passed in point
843
+ * @param {Vector2} pos
844
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
845
+ * @param {Color} [color=WHITE]
846
+ * @param {number} [angle]
847
+ * @param {number} [lineWidth]
848
+ * @param {Color} [lineColor=BLACK]
849
+ * @param {boolean} [useWebGL=glEnable]
850
+ * @param {boolean} [screenSpace]
851
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
852
+ * @memberof Draw */
853
+ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
854
+ {
855
+ ASSERT(isVector2(pos), 'pos must be a vec2');
856
+ ASSERT(isVector2(size), 'size must be a vec2');
857
+ ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
858
+ ASSERT(isNumber(angle), 'angle must be a number');
859
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
860
+ ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
861
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
862
+
863
+ // clamp line width to prevent artifacts
864
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
865
+
866
+ if (useWebGL && glEnable)
867
+ {
868
+ // draw as a regular polygon
869
+ const sides = glCircleSides;
870
+ drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
871
+ }
872
+ else
873
+ {
874
+ drawCanvas2D(pos, vec2(1), angle, false, context=>
875
+ {
876
+ context.fillStyle = color.toString();
877
+ context.beginPath();
878
+ context.ellipse(0, 0, size.x/2, size.y/2, 0, 0, 9);
879
+ context.fill();
880
+ if (lineWidth)
881
+ {
882
+ context.strokeStyle = lineColor.toString();
883
+ context.lineWidth = lineWidth;
884
+ context.stroke();
885
+ }
886
+ }, screenSpace, context);
887
+ }
888
+ }
889
+
890
+ /** Draw colored circle using passed in point
891
+ * @param {Vector2} pos
892
+ * @param {number} [size=1] - Diameter
893
+ * @param {Color} [color=WHITE]
894
+ * @param {number} [lineWidth=0]
895
+ * @param {Color} [lineColor=BLACK]
896
+ * @param {boolean} [useWebGL=glEnable]
897
+ * @param {boolean} [screenSpace]
898
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
899
+ * @memberof Draw */
900
+ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
901
+ {
902
+ ASSERT(isNumber(size), 'size must be a number');
903
+ drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
904
+ }
905
+
906
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
907
+ * - Best when batched with other untextured polys
908
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
909
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
910
+ * @param {Vector2} pos
911
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
912
+ * @param {Color} [colorInner=WHITE]
913
+ * @param {Color} [colorOuter=CLEAR_WHITE]
914
+ * @param {number} [angle]
915
+ * @param {boolean} [useWebGL=glEnable]
916
+ * @param {boolean} [screenSpace]
917
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
918
+ * @memberof Draw */
919
+ let drawEllipseGradientOffset = 0;
920
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
921
+ {
922
+ ASSERT(isVector2(pos), 'pos must be a vec2');
923
+ ASSERT(isVector2(size), 'size must be a vec2');
924
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
925
+ ASSERT(isNumber(angle), 'angle must be a number');
926
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
927
+
928
+ if (headlessMode) return;
929
+
930
+ if (useWebGL && glEnable)
931
+ {
932
+ ASSERT(!!glContext, 'WebGL is not enabled!');
933
+ if (screenSpace)
934
+ {
935
+ // convert to world space
936
+ pos = screenToWorld(pos);
937
+ size = size.scale(1/cameraScale);
938
+ angle += cameraAngle;
939
+ }
940
+ // fan as tristrip; rotate the boundary vertex by one slice per call
941
+ // so back-to-back gradients at the same position have their hole
942
+ // (from gpu edge-rule on the boundary line-degen) at different rim
943
+ // verts and don't visibly stack
944
+ const sides = glCircleSides;
945
+ const radiusX = size.x/2, radiusY = size.y/2;
946
+ const innerInt = colorInner.rgbaInt();
947
+ const outerInt = colorOuter.rgbaInt();
948
+ const offset = drawEllipseGradientOffset++;
949
+ const c = cos(-angle), s = sin(-angle);
950
+ const rim = (a) =>
951
+ {
952
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
953
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
954
+ };
955
+ const startA = (offset%sides)/sides*PI*2;
956
+ const points = [rim(startA)];
957
+ const colors = [outerInt];
958
+ for (let i=sides; i--;)
959
+ {
960
+ const a = ((i+offset)%sides)/sides*PI*2;
961
+ points.push(pos);
962
+ colors.push(innerInt);
963
+ points.push(rim(a));
964
+ colors.push(outerInt);
965
+ }
966
+ glDrawColoredPoints(points, colors);
967
+ }
968
+ else
969
+ {
970
+ // normal canvas 2D rendering method (slower)
971
+ ++drawCount;
972
+ ++primitiveCount;
973
+ drawCanvas2D(pos, size, angle, false, (context)=>
974
+ {
975
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
976
+ gradient.addColorStop(0, colorInner.toString());
977
+ gradient.addColorStop(1, colorOuter.toString());
978
+ context.fillStyle = gradient;
979
+ context.beginPath();
980
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
981
+ context.fill();
982
+ }, screenSpace, context);
983
+ }
984
+ }
985
+
986
+ /** Draw a circle filled with a radial gradient from the center to the rim
987
+ * - Best when batched with other untextured polys
988
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
989
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
990
+ * @param {Vector2} pos
991
+ * @param {number} [size=1] - Diameter
992
+ * @param {Color} [colorInner=WHITE]
993
+ * @param {Color} [colorOuter=CLEAR_WHITE]
994
+ * @param {boolean} [useWebGL=glEnable]
995
+ * @param {boolean} [screenSpace]
996
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
997
+ * @memberof Draw */
998
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
999
+ {
1000
+ ASSERT(isNumber(size), 'size must be a number');
1001
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
1002
+ }
1003
+
1004
+ /**
1005
+ * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
1006
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1007
+ * @memberof Draw
1008
+ */
1009
+
1010
+ /** Draw directly to a 2d canvas context in world space.
1011
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
1012
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
1013
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
1014
+ * @param {Vector2} pos
1015
+ * @param {Vector2} size
1016
+ * @param {number} angle
1017
+ * @param {boolean} [mirror]
1018
+ * @param {Canvas2DDrawFunction} [drawFunction]
1019
+ * @param {boolean} [screenSpace=false]
1020
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1021
+ * @memberof Draw */
1022
+ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=false, context=drawContext)
1023
+ {
1024
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1025
+ ASSERT(isVector2(size), 'size must be a vec2');
1026
+ ASSERT(isNumber(angle), 'angle must be a number');
1027
+ ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
1028
+
1029
+ if (!screenSpace)
1030
+ {
1031
+ pos = worldToScreen(pos);
1032
+ size = size.scale(cameraScale);
1033
+ angle -= cameraAngle;
1034
+ }
1035
+ context.save();
1036
+ context.translate(pos.x+.5, pos.y+.5);
1037
+ context.rotate(angle);
1038
+ context.scale(mirror ? -size.x : size.x, -size.y);
1039
+ drawFunction(context);
1040
+ context.restore();
1041
+ }
1042
+
1043
+ ///////////////////////////////////////////////////////////////////////////////
1044
+ // Text Drawing Functions
1045
+
1046
+ /** Draw text on main canvas in world space
1047
+ * Automatically splits new lines into rows
1048
+ * @param {string|number} text
1049
+ * @param {Vector2} pos
1050
+ * @param {number} [size]
1051
+ * @param {Color} [color=WHITE]
1052
+ * @param {number} [lineWidth]
1053
+ * @param {Color} [lineColor=BLACK]
1054
+ * @param {CanvasTextAlign} [textAlign='center']
1055
+ * @param {string} [font=fontDefault]
1056
+ * @param {string} [fontStyle]
1057
+ * @param {number} [maxWidth]
1058
+ * @param {number} [angle]
1059
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1060
+ * @memberof Draw */
1061
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1062
+ {
1063
+ // convert to screen space
1064
+ pos = worldToScreen(pos);
1065
+ size *= cameraScale;
1066
+ lineWidth *= cameraScale;
1067
+ angle -= cameraAngle;
1068
+ angle *= -1;
1069
+
1070
+ drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1071
+ }
1072
+
1073
+ /** Draw text in screen space
1074
+ * Automatically splits new lines into rows
1075
+ * @param {string|number} text
1076
+ * @param {Vector2} pos
1077
+ * @param {number} size
1078
+ * @param {Color} [color=WHITE]
1079
+ * @param {number} [lineWidth]
1080
+ * @param {Color} [lineColor=BLACK]
1081
+ * @param {CanvasTextAlign} [textAlign]
1082
+ * @param {string} [font=fontDefault]
1083
+ * @param {string} [fontStyle]
1084
+ * @param {number} [maxWidth]
1085
+ * @param {number} [angle]
1086
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1087
+ * @memberof Draw */
1088
+ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1089
+ {
1090
+ ASSERT(isStringLike(text), 'text must be a string');
1091
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1092
+ ASSERT(isNumber(size), 'size must be a number');
1093
+ ASSERT(isColor(color), 'color must be a color');
1094
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
1095
+ ASSERT(isColor(lineColor), 'lineColor must be a color');
1096
+ ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
1097
+ ASSERT(isStringLike(font), 'font must be a string');
1098
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1099
+ ASSERT(isNumber(angle), 'angle must be a number');
1100
+
1101
+ const lines = (text+'').split('\n');
1102
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
1103
+ // save before style mutations so caller's context state is preserved
1104
+ context.save();
1105
+ context.fillStyle = color.toString();
1106
+ context.strokeStyle = lineColor.toString();
1107
+ context.lineWidth = lineWidth;
1108
+ context.textAlign = textAlign;
1109
+ context.font = fontStyle + ' ' + size + 'px '+ font;
1110
+ context.textBaseline = 'middle';
1111
+ context.translate(pos.x, posY);
1112
+ context.rotate(-angle);
1113
+ let yOffset = 0;
1114
+ lines.forEach(line=>
1115
+ {
1116
+ lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
1117
+ context.fillText(line, 0, yOffset, maxWidth);
1118
+ yOffset += size;
1119
+ });
1120
+ context.restore();
1121
+ }
1122
+
1123
+ ///////////////////////////////////////////////////////////////////////////////
1124
+ // Drawing utilities
1125
+
1126
+ /** Load a texture at a specific index
1127
+ * @param {number} textureIndex - Index to store the texture at
1128
+ * @param {string} [src] - Image source path
1129
+ * @return {Promise} Promise that resolves when texture is loaded
1130
+ * @memberof Draw */
1131
+ async function loadTexture(textureIndex, src)
1132
+ {
1133
+ ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
1134
+ ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
1135
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
1136
+
1137
+ const image = new Image;
1138
+ if (src)
1139
+ {
1140
+ await new Promise(resolve =>
1141
+ {
1142
+ image.onerror = image.onload = resolve;
1143
+ image.crossOrigin = 'anonymous';
1144
+ image.src = src;
1145
+ });
1146
+ }
1147
+
1148
+ textureInfos[textureIndex] = new TextureInfo(image);
1149
+ }
1150
+
1151
+ /** Convert from screen to world space coordinates
1152
+ * @param {Vector2} screenPos
1153
+ * @return {Vector2}
1154
+ * @memberof Draw */
1155
+ function screenToWorld(screenPos)
1156
+ {
1157
+ ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1158
+
1159
+ let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
1160
+ let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
1161
+ if (cameraAngle)
1162
+ {
1163
+ // apply camera rotation
1164
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
1165
+ const xr = x * c - y * s, yr = x * s + y * c;
1166
+ x = xr; y = yr;
1167
+ }
1168
+ return new Vector2(x + cameraPos.x, y + cameraPos.y);
1169
+ }
1170
+
1171
+ /** Convert from world to screen space coordinates
1172
+ * @param {Vector2} worldPos
1173
+ * @return {Vector2}
1174
+ * @memberof Draw */
1175
+ function worldToScreen(worldPos)
1176
+ {
1177
+ ASSERT(isVector2(worldPos), 'worldPos must be a vec2');
1178
+
1179
+ let x = worldPos.x - cameraPos.x;
1180
+ let y = worldPos.y - cameraPos.y;
1181
+ if (cameraAngle)
1182
+ {
1183
+ // apply inverse camera rotation
1184
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1185
+ const xr = x * c - y * s, yr = x * s + y * c;
1186
+ x = xr; y = yr;
1187
+ }
1188
+ return new Vector2
1189
+ (
1190
+ x * cameraScale + mainCanvasSize.x/2 - .5,
1191
+ y * -cameraScale + mainCanvasSize.y/2 - .5
1192
+ );
1193
+ }
1194
+
1195
+ /** Convert from screen to world space coordinates for a directional vector (no translation)
1196
+ * @param {Vector2} screenDelta
1197
+ * @return {Vector2}
1198
+ * @memberof Draw */
1199
+ function screenToWorldDelta(screenDelta)
1200
+ {
1201
+ ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2');
1202
+
1203
+ let x = screenDelta.x / cameraScale;
1204
+ let y = screenDelta.y / -cameraScale;
1205
+ if (cameraAngle)
1206
+ {
1207
+ // apply camera rotation
1208
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
1209
+ const xr = x * c - y * s, yr = x * s + y * c;
1210
+ x = xr; y = yr;
1211
+ }
1212
+ return new Vector2(x, y);
1213
+ }
1214
+
1215
+ /** Convert from screen to world space coordinates for a directional vector (no translation)
1216
+ * @param {Vector2} worldDelta
1217
+ * @return {Vector2}
1218
+ * @memberof Draw */
1219
+ function worldToScreenDelta(worldDelta)
1220
+ {
1221
+ ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2');
1222
+
1223
+ let x = worldDelta.x;
1224
+ let y = worldDelta.y;
1225
+ if (cameraAngle)
1226
+ {
1227
+ // apply inverse camera rotation
1228
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1229
+ const xr = x * c - y * s, yr = x * s + y * c;
1230
+ x = xr; y = yr;
1231
+ }
1232
+ return new Vector2(x * cameraScale, y * -cameraScale);
1233
+ }
1234
+
1235
+ /** Convert screen space transform to world space
1236
+ * @param {Vector2} screenPos
1237
+ * @param {Vector2} screenSize
1238
+ * @param {number} [screenAngle]
1239
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
1240
+ * @memberof Draw */
1241
+ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1242
+ {
1243
+ ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1244
+ ASSERT(isVector2(screenSize), 'screenSize must be a vec2');
1245
+ ASSERT(isNumber(screenAngle), 'screenAngle must be a number');
1246
+
1247
+ return [
1248
+ screenToWorld(screenPos),
1249
+ screenSize.scale(1/cameraScale),
1250
+ screenAngle + cameraAngle
1251
+ ];
1252
+ }
1253
+
1254
+ /** Get the size of the camera window in world space
1255
+ * @return {Vector2}
1256
+ * @memberof Draw */
1257
+ function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1258
+
1259
+ /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1260
+ * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1261
+ * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
1262
+ * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
1263
+ * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
1264
+ * or an object with any of {top, right, bottom, left}
1265
+ * @param {Vector2} center - Center of the rectangle in world space
1266
+ * @param {Vector2} size - Size of the rectangle in world space
1267
+ * @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
1268
+ * @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1269
+ * @return {number} - The new camera scale
1270
+ * @memberof Draw */
1271
+ function cameraFit(center, size, worldMargin, screenInset)
1272
+ {
1273
+ ASSERT(isVector2(center), 'center must be a vec2');
1274
+ ASSERT(isVector2(size), 'size must be a vec2');
1275
+
1276
+ // pad the content
1277
+ const margin = padSides(worldMargin);
1278
+ const inset = padSides(screenInset);
1279
+ const worldW = size.x + margin.left + margin.right;
1280
+ const worldH = size.y + margin.top + margin.bottom;
1281
+ const viewW = mainCanvasSize.x - inset.left - inset.right;
1282
+ const viewH = mainCanvasSize.y - inset.top - inset.bottom;
1283
+
1284
+ // bail on a degenerate rect or viewport rather than NaN the camera
1285
+ if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1286
+ return cameraScale;
1287
+
1288
+ // scale to fit the padded content
1289
+ cameraScale = min(viewW / worldW, viewH / worldH);
1290
+
1291
+ // calculate offset vectors
1292
+ const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
1293
+ const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
1294
+
1295
+ // apply the offsets and return camera scale
1296
+ cameraPos = center.add(marginVector).add(insetVector);
1297
+ return cameraScale;
1298
+
1299
+ function padSides(p)
1300
+ {
1301
+ // normalize a padding option to {top, right, bottom, left}
1302
+ if (p === undefined || isNumber(p))
1303
+ p = vec2(p);
1304
+ if (isVector2(p))
1305
+ return { top: p.y, right: p.x, bottom: p.y, left: p.x };
1306
+ return {
1307
+ top: p.top || 0,
1308
+ right: p.right || 0,
1309
+ bottom: p.bottom || 0,
1310
+ left: p.left || 0,
1311
+ };
1312
+ }
1313
+ }
1314
+
1315
+ /** Check if a box, point, or circle is on screen with a circle test
1316
+ * If size is a Vector2, uses the length as diameter
1317
+ * This can be used to cull offscreen objects from render or update
1318
+ * @param {Vector2} pos - world space position
1319
+ * @param {Vector2|number} size - world space size or diameter
1320
+ * @return {boolean}
1321
+ * @memberof Draw */
1322
+ function isOnScreen(pos, size=0)
1323
+ {
1324
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1325
+ ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
1326
+
1327
+ // cameraScale of 0 collapses world coords; nothing is visible
1328
+ if (!cameraScale) return false;
1329
+
1330
+ // optimized circle on screen test
1331
+ // pos = worldToScreen(pos);
1332
+ let x = pos.x - cameraPos.x;
1333
+ let y = pos.y - cameraPos.y;
1334
+ if (cameraAngle)
1335
+ {
1336
+ // apply inverse camera rotation
1337
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1338
+ const xr = x * c - y * s, yr = x * s + y * c;
1339
+ x = xr; y = yr;
1340
+ }
1341
+ x *= cameraScale*2; y *= -cameraScale*2;
1342
+
1343
+ if (size instanceof Vector2)
1344
+ size = size.length(); // use length of vector as diameter
1345
+ size *= cameraScale;
1346
+
1347
+ // check against screen bounds
1348
+ const w = mainCanvasSize.x, h = mainCanvasSize.y;
1349
+ return x + size > -w && x - size < w &&
1350
+ y + size > -h && y - size < h;
1351
+ }
1352
+
1353
+ /** Enable additive blending
1354
+ * @param {boolean} [additive]
1355
+ * @memberof Draw */
1356
+ function setAdditiveBlendMode(additive=true)
1357
+ {
1358
+ glAdditive = additive;
1359
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1360
+ }
1361
+
1362
+ /** Set the Shader that 2D draws use from now on, none for the engine's own
1363
+ * - The object render loop sets each object's own shader, so this is for draws in gameRender and gameRenderPost
1364
+ * @param {Shader} [shader]
1365
+ * @memberof Draw */
1366
+ function setShader(shader)
1367
+ {
1368
+ ASSERT(!shader || shader instanceof Shader, 'shader must be a Shader');
1369
+ glCustomShader = shader || undefined; // null is no shader too, so it batches with none
1370
+ }
1371
+
1372
+ /** Set an extra canvas to composite behind the engine canvases when combining
1373
+ * Plugins that insert their own canvas below the LittleJS canvases should set
1374
+ * this so it appears in screenshots and video capture
1375
+ * @param {HTMLCanvasElement} [canvas]
1376
+ * @memberof Draw */
1377
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
1378
+
1379
+ /** Combines LittleJS canvases onto the main canvas
1380
+ * This is necessary for things like screenshots and video
1381
+ * @memberof Draw */
1382
+ function combineCanvases()
1383
+ {
1384
+ // this composites raw canvases so it works in backing store pixels,
1385
+ // mainCanvasSize is css pixels and would throw away resolution
1386
+ const w = mainCanvas.width, h = mainCanvas.height;
1387
+ workCanvas.width = w;
1388
+ workCanvas.height = h;
1389
+ // remove background alpha explicit fillStyle so a previous caller
1390
+ // leaving workContext.fillStyle transparent can't silently no-op this
1391
+ workContext.fillStyle = '#000';
1392
+ workContext.fillRect(0,0,w,h);
1393
+ if (backgroundCanvas)
1394
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
1395
+ glCopyToContext(workContext);
1396
+ workContext.drawImage(mainCanvas, 0, 0);
1397
+
1398
+ // draw back 1:1, mainContext is scaled to css pixels
1399
+ mainContext.save();
1400
+ mainContext.setTransform(1, 0, 0, 1, 0, 0);
1401
+ mainContext.drawImage(workCanvas, 0, 0);
1402
+ mainContext.restore();
1403
+ }
1404
+
1405
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
1406
+ // image's native resolution. Returns the work canvas, suitable for
1407
+ // passing to context.createPattern. Used by drawTextureWrapped's
1408
+ // Canvas2D path. Caller is responsible for short-circuiting when no
1409
+ // tint is needed (i.e. color is white and additiveColor is black/none).
1410
+ function bakeTintedImage(image, color, additiveColor)
1411
+ {
1412
+ const w = image.width|0, h = image.height|0;
1413
+ workReadCanvas.width = w;
1414
+ workReadCanvas.height = h;
1415
+ workReadContext.drawImage(image, 0, 0);
1416
+
1417
+ const imageData = workReadContext.getImageData(0, 0, w, h);
1418
+ const data = imageData.data;
1419
+ if (additiveColor && !isBlack(additiveColor))
1420
+ {
1421
+ // multiply + additive (slower)
1422
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1423
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1424
+ additiveColor.b * 255, additiveColor.a * 255];
1425
+ for (let i = 0; i < data.length; ++i)
1426
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1427
+ }
1428
+ else
1429
+ {
1430
+ // RGB only, faster — alpha left intact for the caller
1431
+ for (let i = 0; i < data.length; i+=4)
1432
+ {
1433
+ data[i ] *= color.r;
1434
+ data[i+1] *= color.g;
1435
+ data[i+2] *= color.b;
1436
+ }
1437
+ }
1438
+ workReadContext.putImageData(imageData, 0, 0);
1439
+ return workReadCanvas;
1440
+ }
1441
+
1442
+ /** Helper function to draw an image with color and additive color applied
1443
+ * This is slower then normal drawImage when color is applied
1444
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1445
+ * @param {HTMLImageElement|OffscreenCanvas} image
1446
+ * @param {number} sx
1447
+ * @param {number} sy
1448
+ * @param {number} sWidth
1449
+ * @param {number} sHeight
1450
+ * @param {number} dx
1451
+ * @param {number} dy
1452
+ * @param {number} dWidth
1453
+ * @param {number} dHeight
1454
+ * @param {Color} color
1455
+ * @param {Color} [additiveColor]
1456
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1457
+ * @memberof Draw */
1458
+ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1459
+ {
1460
+ const sx2 = bleed;
1461
+ const sy2 = bleed;
1462
+ sWidth = max(1,sWidth|0);
1463
+ sHeight = max(1,sHeight|0);
1464
+ const sWidth2 = sWidth - 2*bleed;
1465
+ const sHeight2 = sHeight - 2*bleed;
1466
+ if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
1467
+ {
1468
+ // white texture with no additive alpha, no need to tint
1469
+ context.globalAlpha = color.a;
1470
+ context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1471
+ context.globalAlpha = 1;
1472
+ }
1473
+ else
1474
+ {
1475
+ // copy to offscreen canvas
1476
+ workReadCanvas.width = sWidth;
1477
+ workReadCanvas.height = sHeight;
1478
+ workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
1479
+
1480
+ // tint image using offscreen work context
1481
+ const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
1482
+ const data = imageData.data;
1483
+ if (additiveColor && !isBlack(additiveColor))
1484
+ {
1485
+ // slower path with additive color
1486
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1487
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255, additiveColor.b * 255, additiveColor.a * 255];
1488
+ for (let i = 0; i < data.length; ++i)
1489
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1490
+ workReadContext.putImageData(imageData, 0, 0);
1491
+ context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1492
+ }
1493
+ else
1494
+ {
1495
+ // faster path with no additive color
1496
+ for (let i = 0; i < data.length; i+=4)
1497
+ {
1498
+ data[i ] *= color.r;
1499
+ data[i+1] *= color.g;
1500
+ data[i+2] *= color.b;
1501
+ }
1502
+ workReadContext.putImageData(imageData, 0, 0);
1503
+ context.globalAlpha = color.a;
1504
+ context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1505
+ context.globalAlpha = 1;
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+
1511
+ /** Returns true if fullscreen mode is active
1512
+ * @return {boolean}
1513
+ * @memberof Draw */
1514
+ function isFullscreen() { return !!document.fullscreenElement; }
1515
+
1516
+ /** Toggle fullscreen mode
1517
+ * @memberof Draw */
1518
+ function toggleFullscreen()
1519
+ {
1520
+ const rootElement = mainCanvas.parentElement;
1521
+ if (isFullscreen())
1522
+ {
1523
+ if (document.exitFullscreen)
1524
+ document.exitFullscreen();
1525
+ }
1526
+ else if (rootElement.requestFullscreen)
1527
+ rootElement.requestFullscreen();
1528
+ }
1529
+
1530
+ /** Set the cursor style
1531
+ * @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc)
1532
+ * @memberof Draw */
1533
+ function setCursor(cursorStyle = 'auto')
1534
+ {
1535
+ const rootElement = mainCanvas.parentElement;
1536
+ rootElement.style.cursor = cursorStyle;
1537
+ }
1538
+
1539
+ ///////////////////////////////////////////////////////////////////////////////
1540
+
1541
+ /** Engine font image, 8x8 font provided by the engine
1542
+ * @type {ImageFont}
1543
+ * @memberof Draw */
1544
+ let engineImageFont;
1545
+
1546
+ /**
1547
+ * Image Font Object - Draw text by using tiles in an image
1548
+ * - 96 characters (from space to tilde) are stored in an image
1549
+ * - A 8x8 default engine font is supplied for general use
1550
+ * - This system is WebGL enabled for fast text rendering
1551
+ * - Fonts can also be colored and scaled along each axis
1552
+ *
1553
+ * @memberof Draw
1554
+ * @example
1555
+ * // use built in font
1556
+ * const font = engineImageFont;
1557
+ *
1558
+ * // draw text
1559
+ * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1560
+ */
1561
+ class ImageFont
1562
+ {
1563
+ /** Create an image font
1564
+ * @param {TileInfo} tileInfo - Tile info of first character in font
1565
+ */
1566
+ constructor(tileInfo)
1567
+ {
1568
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1569
+
1570
+ /** @property {TileInfo} - Tile info for the font */
1571
+ this.tileInfo = tileInfo.frame(0);
1572
+ }
1573
+
1574
+ /** Draw text in world space using the image font
1575
+ * @param {string|number} text
1576
+ * @param {Vector2} pos
1577
+ * @param {Vector2|number} [size]
1578
+ * @param {boolean} [center=true]
1579
+ * @param {Color} [color=WHITE]
1580
+ * @param {boolean} [useWebGL=glEnable]
1581
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1582
+ */
1583
+ drawText(text, pos, size=1, center, color, useWebGL, context)
1584
+ {
1585
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1586
+
1587
+ if (typeof size === 'number')
1588
+ {
1589
+ // if size is a number, make it a vector
1590
+ ASSERT(size > 0);
1591
+ size *= cameraScale;
1592
+ size = new Vector2(size, size);
1593
+ }
1594
+ else
1595
+ size = size.scale(cameraScale);
1596
+ this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1597
+ }
1598
+
1599
+ /** Draw text in screen space using the image font
1600
+ * @param {string|number} text
1601
+ * @param {Vector2} pos
1602
+ * @param {Vector2|number} size
1603
+ * @param {boolean} [center]
1604
+ * @param {Color} [color=WHITE]
1605
+ * @param {boolean} [useWebGL=glEnable]
1606
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1607
+ */
1608
+ drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
1609
+ {
1610
+ ASSERT(isStringLike(text), 'text must be a string');
1611
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1612
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1613
+ ASSERT(isColor(color), 'color must be a color');
1614
+
1615
+ // if size is a number, make it a vector
1616
+ size = typeof size === 'number' ? new Vector2(size, size) : size;
1617
+
1618
+ // precache objects for drawing
1619
+ const drawPos = new Vector2;
1620
+ const tileInfo = this.tileInfo;
1621
+ const padding = tileInfo.padding;
1622
+ const sizePaddedX = tileInfo.size.x + padding*2;
1623
+ const sizePaddedY = tileInfo.size.y + padding*2;
1624
+ const cols = tileInfo.textureInfo.size.x / sizePaddedX |0;
1625
+
1626
+ // draw each line of text
1627
+ (text+'').split('\n').forEach((line, j)=>
1628
+ {
1629
+ const centerOffset = center ? (line.length-1) * size.x / 2 : 0;
1630
+ for (let i=line.length; i--;)
1631
+ {
1632
+ // get the character index
1633
+ const charCode = line.charCodeAt(i);
1634
+ const index = charCode < 32 || charCode > 127 ?
1635
+ 95 : charCode - 32; // handle out of range characters
1636
+
1637
+ // get the position of the tile
1638
+ const x = index % cols;
1639
+ const y = index / cols |0;
1640
+ tileInfo.pos.x = x*sizePaddedX + padding;
1641
+ tileInfo.pos.y = y*sizePaddedY + padding;
1642
+
1643
+ // snap the glyph edges to whole pixels
1644
+ // tiles are drawn from their center, so snapping the center
1645
+ // to a whole pixel puts the edges on half pixels when the
1646
+ // size is even, and a row or column of the glyph then has
1647
+ // no pixel center inside it and is not rasterized at all
1648
+ // ceil picks the nearest aligned position, breaking ties
1649
+ // downward to match how this used to truncate
1650
+ drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
1651
+ drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
1652
+ drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1653
+ }
1654
+ });
1655
+ }
1656
+ }
1657
+
1658
+ // load engine font, called automatically on startup
1659
+ async function imageFontInit()
1660
+ {
1661
+ const image = new Image;
1662
+ await new Promise(resolve =>
1663
+ {
1664
+ image.onerror = image.onload = resolve;
1665
+ image.crossOrigin = 'anonymous';
1666
+ image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg==';
1667
+ });
1668
+
1669
+ const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
1670
+ const textureInfo = new TextureInfo(image);
1671
+ const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
1672
+ engineImageFont = new ImageFont(tileInfo);
1511
1673
  }